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

resolve conflict Merge branch 'master' into feat/questionnaire

Taichi Masuyama 3 лет назад
Родитель
Сommit
a3cd7939a0
100 измененных файлов с 1201 добавлено и 601 удалено
  1. 0 2
      .github/workflows/ci-app.yml
  2. 0 1
      .github/workflows/ci-slackbot-proxy.yml
  3. 53 34
      .github/workflows/release-rc.yml
  4. 65 39
      .github/workflows/release.yml
  5. 56 0
      .github/workflows/reusable-app-build-image.yml
  6. 52 0
      .github/workflows/reusable-app-create-manifests.yml
  7. 5 0
      .gitignore
  8. 9 0
      .mergify.yml
  9. 49 2
      CHANGELOG.md
  10. 1 1
      lerna.json
  11. 1 1
      package.json
  12. 0 1
      packages/app/.env.development
  13. 5 5
      packages/app/docker/Dockerfile
  14. 1 1
      packages/app/docker/README.md
  15. 65 0
      packages/app/docker/codebuild/.terraform.lock.hcl
  16. 32 0
      packages/app/docker/codebuild/buildspec.yml
  17. 25 0
      packages/app/docker/codebuild/codebuild.tf
  18. 23 0
      packages/app/docker/codebuild/main.tf
  19. 26 0
      packages/app/docker/codebuild/oidc.tf
  20. 15 0
      packages/app/docker/codebuild/secretsmanager.tf
  21. 0 6
      packages/app/docker/nocdn/.env.production.local
  22. 11 10
      packages/app/package.json
  23. 12 0
      packages/app/public/static/locales/en_US/translation.json
  24. 13 0
      packages/app/public/static/locales/ja_JP/translation.json
  25. 12 0
      packages/app/public/static/locales/zh_CN/translation.json
  26. 1 11
      packages/app/resource/locales/en_US/sandbox.md
  27. 0 10
      packages/app/resource/locales/ja_JP/sandbox.md
  28. 1 11
      packages/app/resource/locales/zh_CN/sandbox.md
  29. 2 3
      packages/app/src/client/services/layout.ts
  30. 1 1
      packages/app/src/components/Admin/AdminHome/SystemInfomationTable.tsx
  31. 31 42
      packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx
  32. 3 4
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  33. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  34. 33 1
      packages/app/src/components/Fab.module.scss
  35. 50 34
      packages/app/src/components/Fab.tsx
  36. 4 1
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  37. 1 1
      packages/app/src/components/Layout/BasicLayout.tsx
  38. 1 1
      packages/app/src/components/Layout/MainPane.tsx
  39. 2 1
      packages/app/src/components/Layout/RawLayout.tsx
  40. 1 1
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  41. 1 1
      packages/app/src/components/Me/ApiSettings.tsx
  42. 2 2
      packages/app/src/components/Me/ProfileImageSettings.tsx
  43. 3 3
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  44. 1 1
      packages/app/src/components/Navbar/AuthorInfo.tsx
  45. 29 24
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  46. 8 8
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  47. 0 9
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  48. 0 138
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  49. 9 0
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.module.scss
  50. 103 0
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx
  51. 17 9
      packages/app/src/components/Page.tsx
  52. 2 2
      packages/app/src/components/Page/DisplaySwitcher.tsx
  53. 5 3
      packages/app/src/components/Page/TagEditModal.jsx
  54. 3 1
      packages/app/src/components/Page/TagsInput.tsx
  55. 1 1
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  56. 10 3
      packages/app/src/components/PageEditor.tsx
  57. 6 1
      packages/app/src/components/PageEditor/DrawioModal.tsx
  58. 6 1
      packages/app/src/components/PageEditor/ScrollSyncHelper.js
  59. 12 3
      packages/app/src/components/PageEditorByHackmd.tsx
  60. 1 1
      packages/app/src/components/PageRenameModal.tsx
  61. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  62. 27 1
      packages/app/src/components/Questionnaire/QuestionnaireModalManager.tsx
  63. 2 2
      packages/app/src/components/SearchPage.tsx
  64. 2 2
      packages/app/src/components/SearchPage/SearchControl.tsx
  65. 0 0
      packages/app/src/components/SearchPage/SearchPageBase.module.scss
  66. 3 2
      packages/app/src/components/SearchPage/SearchPageBase.tsx
  67. 8 0
      packages/app/src/components/SearchPage/SearchResultContent.module.scss
  68. 3 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  69. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  70. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  71. 20 20
      packages/app/src/components/StickyStretchableScroller.tsx
  72. 1 1
      packages/app/src/components/User/UserDate.jsx
  73. 1 1
      packages/app/src/interfaces/customize.ts
  74. 48 8
      packages/app/src/pages/[[...path]].page.tsx
  75. 6 3
      packages/app/src/pages/_app.page.tsx
  76. 6 51
      packages/app/src/pages/_document.page.tsx
  77. 4 1
      packages/app/src/pages/_search.page.tsx
  78. 1 7
      packages/app/src/pages/admin/[...path].page.tsx
  79. 4 1
      packages/app/src/pages/admin/customize.page.tsx
  80. 2 1
      packages/app/src/pages/admin/index.page.tsx
  81. 3 1
      packages/app/src/pages/installer.page.tsx
  82. 3 1
      packages/app/src/pages/invited.page.tsx
  83. 3 2
      packages/app/src/pages/login/index.page.tsx
  84. 1 1
      packages/app/src/pages/me/[[...path]].page.tsx
  85. 46 9
      packages/app/src/pages/share/[[...path]].page.tsx
  86. 1 1
      packages/app/src/pages/tags.page.tsx
  87. 5 2
      packages/app/src/pages/trash.page.tsx
  88. 3 1
      packages/app/src/pages/user-activation.page.tsx
  89. 11 8
      packages/app/src/pages/utils/commons.ts
  90. 2 1
      packages/app/src/server/crowi/index.js
  91. 8 0
      packages/app/src/server/middlewares/certify-brand-logo.ts
  92. 6 0
      packages/app/src/server/middlewares/login-required.js
  93. 1 4
      packages/app/src/server/models/config.ts
  94. 1 14
      packages/app/src/server/routes/apiv3/customize-setting.js
  95. 10 0
      packages/app/src/server/routes/attachment.js
  96. 4 0
      packages/app/src/server/routes/index.js
  97. 11 0
      packages/app/src/server/service/attachment.js
  98. 0 6
      packages/app/src/server/service/config-loader.ts
  99. 41 1
      packages/app/src/server/service/customize.ts
  100. 18 7
      packages/app/src/server/service/plugin.ts

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

@@ -5,8 +5,6 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - chore/**
-      - support/prepare-v**
     paths:
       - .github/workflows/ci-app.yml
       - .eslint*

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

@@ -5,7 +5,6 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - chore/**
       - support/prepare-v**
     paths:
       - .github/workflows/ci-slackbot-proxy.yml

+ 53 - 34
.github/workflows/release-rc.yml

@@ -5,60 +5,79 @@ on:
     branches:
       - rc/**
 
-jobs:
 
-  build-rc:
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+
+jobs:
 
+  determine-tags:
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        lfs: true
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
 
-    - name: Docker meta
+    - name: Docker meta for docker.io
+      uses: docker/metadata-action@v4
       id: meta
+      with:
+        images: docker.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
+
+    - name: Docker meta for ghcr.io
       uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
-    - name: Login to docker.io registry
-      run: |
-        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+  build-image-rc:
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+  publish-image-rc:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-rc-ghcr:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Build and push
-      uses: docker/build-push-action@v2
-      with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}

+ 65 - 39
.github/workflows/release.yml

@@ -116,68 +116,95 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog
-        pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+        pr_label: exclude from changelog,prepare next version
+        pr_body: "An automated PR generated by create-pr-for-next-rc"
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 
-  build-image:
+  determine-tags:
     needs: create-github-release
-
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
-        lfs: true
 
-    - name: Docker meta
-      id: meta
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@1.2.0
+      id: package-json
+
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v4
+      id: meta
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: docker.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=latest
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to Docker Hub
-      uses: docker/login-action@v2
+    - name: Docker meta for ghcr.io
+      uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=latest
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
+  build-image:
+    needs: create-github-release
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      source-version: refs/tags/v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+
+
+  publish-image:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-ghcr:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+
+  post-publish:
+    needs: [create-github-release, publish-image, publish-image-ghcr]
+    runs-on: ubuntu-latest
 
-    - name: Build and push
-      uses: docker/build-push-action@v3
+    steps:
+    - uses: actions/checkout@v3
       with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}
+        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v3
@@ -193,7 +220,6 @@ jobs:
         channel: '#release'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         created_tag: 'v${{ needs.create-github-release.outputs.RELEASED_VERSION }}'
-        message: '*Release v${{ needs.create-github-release.outputs.RELEASED_VERSION }} (${{ matrix.platform }})* Succeeded'
 
     - name: Check whether workspace is clean
       run: |

+ 56 - 0
.github/workflows/reusable-app-build-image.yml

@@ -0,0 +1,56 @@
+name: Reusable build app container image workflow
+
+on:
+  workflow_call:
+    inputs:
+      source-version:
+        type: string
+        default: ${{ github.sha }}
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC:
+        required: true
+
+
+
+jobs:
+
+  build-image:
+    runs-on: ubuntu-latest
+
+    # These permissions are needed to interact with GitHub's OIDC Token endpoint.
+    permissions:
+      id-token: write
+      contents: write
+
+    strategy:
+      matrix:
+        platform: [amd64, arm64]
+
+    steps:
+    - uses: actions/checkout@v3
+
+    - name: Configure AWS Credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-region: ap-northeast-1
+        role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+        role-session-name: SessionForReleaseGROWI-RC
+
+    - name: Run CodeBuild
+      uses: dark-mechanicum/aws-codebuild@v1
+      with:
+        projectName: growi-official-image-builder
+      env:
+        CODEBUILD__sourceVersion: ${{ inputs.source-version }}
+        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/standard:6.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }}
+        CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
+        CODEBUILD__environmentVariablesOverride: '[
+          { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" },
+          { "name": "IMAGE_TAG_GHCR", "type": "PLAINTEXT", "value": "ghcr.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }
+        ]'

+ 52 - 0
.github/workflows/reusable-app-create-manifests.yml

@@ -0,0 +1,52 @@
+name: Reusable create app container image manifests workflow
+
+on:
+  workflow_call:
+    inputs:
+      tags:
+        type: string
+        required: true
+      registry:
+        type: string
+        default: 'docker.io'
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD:
+        required: true
+
+
+
+jobs:
+
+  create-manifest:
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Docker meta for extra-images
+      id: meta-extra-images
+      uses: docker/metadata-action@v4
+      with:
+        images: ${{ inputs.registry }}/${{ inputs.image-name }}
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ inputs.tag-temporary }}-amd64
+          type=raw,value=${{ inputs.tag-temporary }}-arm64
+
+    - name: Login to Container Registry
+      uses: docker/login-action@v2
+      with:
+        registry: ${{ inputs.registry }}
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+    - name: Create and push manifest images
+      uses: Noelware/docker-manifest-action@master
+      with:
+        base-image: ${{ inputs.tags }}
+        extra-images: ${{ steps.meta-extra-images.outputs.tags }}
+        push: true

+ 5 - 0
.gitignore

@@ -29,6 +29,11 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 
+# Terraform
+**/.terraform/*
+# *.tfstate
+*.tfstate.*
+
 # IDE, dev #
 .idea
 *.orig

+ 9 - 0
.mergify.yml

@@ -11,3 +11,12 @@ pull_request_rules:
     actions:
       merge:
         method: merge
+
+  - name: Automatic merge for Preparing next version
+    conditions:
+      - author = github-actions[bot]
+      - '#approved-reviews-by >= 1'
+      - label = "prepare next version"
+    actions:
+      merge:
+        method: merge

+ 49 - 2
CHANGELOG.md

@@ -1,10 +1,49 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.0.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.0.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
-## [v6.0.0](https://github.com/weseek/growi/compare/v5.1.7...v6.0.0) - 2022-12-27
+## [v6.0.2](https://github.com/weseek/growi/compare/v6.0.1...v6.0.2) - 2023-01-10
+
+### 🐛 Bug Fixes
+
+- fix: Attaching page title as initial header section (#7228) @yukendev
+- fix: Update PageTree data after saving page (#7227) @yukendev
+- fix: Lsx "filter" and "except" options does not work (#7226) @yuki-takei
+- fix: Omit remark-growi-directive shortcuts (#7225) @yuki-takei
+
+### 🧰 Maintenance
+
+- ci(deps-dev): bump textlint-rule-no-doubled-joshi from 4.0.0 to 4.0.1 (#7222) @dependabot
+
+## [v6.0.1](https://github.com/weseek/growi/compare/v6.0.0...v6.0.1) - 2023-01-07
+
+### 🚀 Improvement
+
+- imprv: Reduce frequent API calling by SWR (#7218) @yuki-takei
+- imprv: Do not use api for fetching pages when using shared pages (#7213) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Custom logo not displayed on shared page (#7205) @miya
+- fix: Attach i18n User Setting and TagEditModal (#7216) @jam411
+- fix: Make PLANTUML_URI v5.x compatible (#7215) @yuki-takei
+- fix: Launch with PROMSTER_ENABLED=true failed (#7210) @yuki-takei
+- fix: Lsx performs with strange behavior (#7209) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Arm architecture (#7212) @yuki-takei
+- ci(deps): bump anothrNick/github-tag-action from 1.38.0 to 1.56.0 (#7195) @dependabot
+- ci(deps): bump google-github-actions/setup-gcloud from 0 to 1 (#7193) @dependabot
+- ci(deps): bump github/codeql-action from 1 to 2 (#7194) @dependabot
+- ci(deps): bump flat from 5.0.0 to 5.0.2 (#7200) @dependabot
+- ci(deps): bump json5 from 1.0.1 to 1.0.2 (#7201) @dependabot
+- ci(Mergify): configuration update (#7202) @yuki-takei
+- support: Uninstall swig-template (#7192) @yuki-takei
+
+## [v6.0.0](https://github.com/weseek/growi/compare/v5.1.8...v6.0.0) - 2022-12-27
 
 ### 💎 Features
 
@@ -17,6 +56,14 @@
 - support: Request scoped SWR (#6742) @yuki-takei
 - support: Build preset themes within external package (#7057) @yuki-takei
 
+## [v5.1.8](https://github.com/weseek/growi/compare/v5.1.7...v5.1.8) - 2022-11-17
+
+### 🐛 Bug Fixes
+
+- fix: Put back page from trash (#6835) @yukendev
+- fix: Updating page content width is not working (#6914) @yukendev
+- fix: Create page at installer (#6930) @hakumizuki @yuki-takei
+
 ## [v5.1.7](https://github.com/weseek/growi/compare/v5.1.6...v5.1.7) - 2022-10-26
 
 ### 🐛 Bug Fixes

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "6.0.1-RC.0",
+  "version": "6.0.3-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.0.1-RC.0",
+  "version": "6.0.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 0 - 1
packages/app/.env.development

@@ -7,7 +7,6 @@ MIGRATIONS_DIR=src/migrations/
 APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
-# NO_CDN=true
 MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"

+ 5 - 5
packages/app/docker/Dockerfile

@@ -95,16 +95,16 @@ WORKDIR ${optDir}
 # ignore eslint and stylelint
 COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
-COPY packages/app packages/app
-COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
+COPY packages/preset-themes packages/preset-themes
 COPY packages/slack packages/slack
-COPY packages/ui packages/ui
+COPY packages/hackmd packages/hackmd
 COPY packages/remark-drawio packages/remark-drawio
 COPY packages/remark-growi-directive packages/remark-growi-directive
 COPY packages/remark-lsx packages/remark-lsx
-COPY packages/hackmd packages/hackmd
-COPY packages/preset-themes packages/preset-themes
+COPY packages/ui packages/ui
+COPY packages/core packages/core
+COPY packages/app packages/app
 
 # build
 RUN yarn lerna run build

+ 1 - 1
packages/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`6.0.0`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.0/packages/app/docker/Dockerfile)
+* [`6.0.2`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.2/packages/app/docker/Dockerfile)
 * [`5.1.7`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
 * [`5.1.7-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 65 - 0
packages/app/docker/codebuild/.terraform.lock.hcl

@@ -0,0 +1,65 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+  version     = "4.49.0"
+  constraints = "~> 4.16"
+  hashes = [
+    "h1:oOwWQpvQWd1uVP1axBz/TO6xzzLWoL982AY/MQfeF7I=",
+    "zh:09803937f00fdf2873eccf685eec7854408925cbf183c9b683df7c5825be463f",
+    "zh:2af1575e538fb0b669266f8d1385b17bfdaf17c521b6b6329baa1f2971fc4a4d",
+    "zh:3f71882b438cde3108fe68cfe2637839d3eed08157a9721bd97babf3912247a8",
+    "zh:577af1b38f5da8a9f29eebe5eebec9279d26e757cd03b0c8c59311f9ce8a859b",
+    "zh:60160d39094973beefb9b10cfd6aaa5b63a2e68c32445ecffcd1b101356e6f9b",
+    "zh:762656454722548baeccf35cbaa23b887976337e1ed321682df7390419fdf22d",
+    "zh:7f6d7887821659bf3bef815949077dc91ffcdb0d911644a887b6683b264a5ca6",
+    "zh:8f16a352cc903f8951fa4619c36233b3e66e27d724817b131f2035dd8896f524",
+    "zh:8f768f65e370366c8b91c00d01c9a6264fe26ea9ae1819f14bdcd12c066272bc",
+    "zh:95ad78c689a83c08ef7c3e544c3c9aca93ed528054aa77cc968ddd9efa3a1023",
+    "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+    "zh:a47097ab6a4ca8302da82964303ffdd2310ed65e8f8524bfe4058816cf1addb7",
+    "zh:b66d820c70cd5fd628ffe882d2b97e76b969dca4e6827ac2ba0f8d3bc5d6e9c6",
+    "zh:b80f713a4f3e1355c3dd1600e9d08b9f15ed2370054ec792ad2c01f2541abe02",
+    "zh:ce065bc3962cb71fa7652562226b9d486f3d7fcb88285c1020ebe2f550e28641",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+  version     = "3.4.3"
+  constraints = ">= 2.1.0"
+  hashes = [
+    "h1:xZGZf18JjMS06pFa4NErzANI98qi59SEcBsOcS2P2yQ=",
+    "zh:41c53ba47085d8261590990f8633c8906696fa0a3c4b384ff6a7ecbf84339752",
+    "zh:59d98081c4475f2ad77d881c4412c5129c56214892f490adf11c7e7a5a47de9b",
+    "zh:686ad1ee40b812b9e016317e7f34c0d63ef837e084dea4a1f578f64a6314ad53",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:84103eae7251384c0d995f5a257c72b0096605048f757b749b7b62107a5dccb3",
+    "zh:8ee974b110adb78c7cd18aae82b2729e5124d8f115d484215fd5199451053de5",
+    "zh:9dd4561e3c847e45de603f17fa0c01ae14cae8c4b7b4e6423c9ef3904b308dda",
+    "zh:bb07bb3c2c0296beba0beec629ebc6474c70732387477a65966483b5efabdbc6",
+    "zh:e891339e96c9e5a888727b45b2e1bb3fcbdfe0fd7c5b4396e4695459b38c8cb1",
+    "zh:ea4739860c24dfeaac6c100b2a2e357106a89d18751f7693f3c31ecf6a996f8d",
+    "zh:f0c76ac303fd0ab59146c39bc121c5d7d86f878e9a69294e29444d4c653786f8",
+    "zh:f143a9a5af42b38fed328a161279906759ff39ac428ebcfe55606e05e1518b93",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/tls" {
+  version     = "4.0.4"
+  constraints = ">= 3.0.0"
+  hashes = [
+    "h1:pe9vq86dZZKCm+8k1RhzARwENslF3SXb9ErHbQfgjXU=",
+    "zh:23671ed83e1fcf79745534841e10291bbf34046b27d6e68a5d0aab77206f4a55",
+    "zh:45292421211ffd9e8e3eb3655677700e3c5047f71d8f7650d2ce30242335f848",
+    "zh:59fedb519f4433c0fdb1d58b27c210b27415fddd0cd73c5312530b4309c088be",
+    "zh:5a8eec2409a9ff7cd0758a9d818c74bcba92a240e6c5e54b99df68fff312bbd5",
+    "zh:5e6a4b39f3171f53292ab88058a59e64825f2b842760a4869e64dc1dc093d1fe",
+    "zh:810547d0bf9311d21c81cc306126d3547e7bd3f194fc295836acf164b9f8424e",
+    "zh:824a5f3617624243bed0259d7dd37d76017097dc3193dac669be342b90b2ab48",
+    "zh:9361ccc7048be5dcbc2fafe2d8216939765b3160bd52734f7a9fd917a39ecbd8",
+    "zh:aa02ea625aaf672e649296bce7580f62d724268189fe9ad7c1b36bb0fa12fa60",
+    "zh:c71b4cd40d6ec7815dfeefd57d88bc592c0c42f5e5858dcc88245d371b4b8b1e",
+    "zh:dabcd52f36b43d250a3d71ad7abfa07b5622c69068d989e60b79b2bb4f220316",
+    "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+  ]
+}

+ 32 - 0
packages/app/docker/codebuild/buildspec.yml

@@ -0,0 +1,32 @@
+version: 0.2
+
+env:
+  variables:
+    DOCKER_BUILDKIT: 1
+    IMAGE_TAG: ''
+    IMAGE_TAG_GHCR: ''
+  secrets-manager:
+    DOCKER_REGISTRY_PASSWORD: growi/official-image-builder:DOCKER_REGISTRY_PASSWORD
+    DOCKER_REGISTRY_ON_GITHUB_PAT: growi/official-image-builder:DOCKER_REGISTRY_ON_GITHUB_PAT
+
+phases:
+  pre_build:
+    commands:
+      # login to docker.io
+      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username wsmoogle --password-stdin
+      # login to ghcr.io
+      - echo ${DOCKER_REGISTRY_ON_GITHUB_PAT} | docker login ghcr.io --username wsmoogle --password-stdin
+  build:
+    commands:
+      - docker build -t ${IMAGE_TAG} -f ./packages/app/docker/Dockerfile .
+      - docker tag ${IMAGE_TAG} ${IMAGE_TAG_GHCR}
+
+  post_build:
+    commands:
+      - docker push $IMAGE_TAG
+      - docker push $IMAGE_TAG_GHCR
+
+cache:
+  paths:
+    - node_modules/**/*
+    - packages/*/node_modules/**/*

+ 25 - 0
packages/app/docker/codebuild/codebuild.tf

@@ -0,0 +1,25 @@
+module "codebuild" {
+  source = "cloudposse/codebuild/aws"
+
+  name                = "growi-official-image-builder"
+  description         = "The CodeBuild Project for GROWI official docker image"
+
+  artifact_type       = "NO_ARTIFACTS"
+
+  source_type         = "GITHUB"
+  source_location     = "https://github.com/weseek/growi.git"
+  source_version      = "refs/heads/master"
+  git_clone_depth     = 1
+
+  buildspec           = "packages/app/docker/codebuild/buildspec.yml"
+
+  # https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-available.html
+  build_image         = "aws/codebuild/amazonlinux2-x86_64-standard:3.0"
+  build_compute_type  = "BUILD_GENERAL1_LARGE"
+
+  privileged_mode     = true
+
+  cache_type          = "LOCAL"
+  local_cache_modes   = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_CUSTOM_CACHE"]
+
+}

+ 23 - 0
packages/app/docker/codebuild/main.tf

@@ -0,0 +1,23 @@
+terraform {
+  backend "remote" {
+    organization = "weseek"
+
+    workspaces {
+      name = "official-image-builder"
+    }
+  }
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~> 4.16"
+    }
+  }
+
+  required_version = ">= 1.2.0"
+}
+
+provider "aws" {
+  profile = "weseek"
+  region  = "ap-northeast-1"
+}

+ 26 - 0
packages/app/docker/codebuild/oidc.tf

@@ -0,0 +1,26 @@
+module "oidc_github" {
+  source  = "unfunco/oidc-github/aws"
+
+  iam_role_name = "GitHubOIDC-for-growi"
+  iam_role_inline_policies = {
+    "inline_policy" : data.aws_iam_policy_document.policy_document.json
+  }
+
+  github_repositories = [
+    "weseek/growi",
+  ]
+}
+
+data "aws_iam_policy_document" "policy_document" {
+  statement {
+    actions   = [
+      "codebuild:StartBuild",
+      "codebuild:StopBuild",
+      "codebuild:RetryBuild",
+      "codebuild:BatchGetBuilds"
+    ]
+    resources = [
+      module.codebuild.project_arn
+    ]
+  }
+}

+ 15 - 0
packages/app/docker/codebuild/secretsmanager.tf

@@ -0,0 +1,15 @@
+resource "aws_secretsmanager_secret" "secret" {
+  name = "growi/official-image-builder"
+}
+
+resource "aws_secretsmanager_secret_version" "main" {
+  secret_id     = aws_secretsmanager_secret.secret.id
+  secret_string = jsonencode({
+    KEY1 = "CHANGE THIS"
+    KEY2 = "CHANGE THIS"
+  })
+
+  lifecycle {
+    ignore_changes = [secret_string, version_stages]
+  }
+}

+ 0 - 6
packages/app/docker/nocdn/.env.production.local

@@ -1,6 +0,0 @@
-
-##
-## Handled by Next.js with dotenv or dotenv-flow
-## https://nextjs.org/docs/basic-features/environment-variables
-##
-NO_CDN=true

+ 11 - 10
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.0.1-RC.0",
+  "version": "6.0.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -66,14 +66,14 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.1-RC.0",
-    "@growi/core": "^6.0.1-RC.0",
-    "@growi/hackmd": "^6.0.1-RC.0",
-    "@growi/preset-themes": "^6.0.1-RC.0",
-    "@growi/remark-drawio": "^6.0.1-RC.0",
-    "@growi/remark-growi-directive": "^6.0.1-RC.0",
-    "@growi/remark-lsx": "^6.0.1-RC.0",
-    "@growi/slack": "^6.0.1-RC.0",
+    "@growi/codemirror-textlint": "^6.0.3-RC.0",
+    "@growi/core": "^6.0.3-RC.0",
+    "@growi/hackmd": "^6.0.3-RC.0",
+    "@growi/preset-themes": "^6.0.3-RC.0",
+    "@growi/remark-drawio": "^6.0.3-RC.0",
+    "@growi/remark-growi-directive": "^6.0.3-RC.0",
+    "@growi/remark-lsx": "^6.0.3-RC.0",
+    "@growi/slack": "^6.0.3-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -96,6 +96,7 @@
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
     "csv-to-markdown-table": "^1.1.0",
+    "dayjs": "^1.11.7",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
@@ -201,7 +202,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/ui": "^6.0.1-RC.0",
+    "@growi/ui": "^6.0.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 12 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -85,6 +85,7 @@
   "No diff": "No diff",
   "User ID": "User ID",
   "User Information": "User information",
+  "User Activation": "User Activation",
   "Basic Info": "Basic info",
   "Name": "Name",
   "Email": "Email",
@@ -164,6 +165,7 @@
     "no_page_list": "There are no pages under this page."
   },
   "installer": {
+    "title": "Installer",
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
     "initial_account_will_be_administrator_automatically": "The initial account will be administrator automatically.",
@@ -551,6 +553,7 @@
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
   },
   "search_result": {
+    "title": "Search",
     "result_meta": "Search results for:",
     "deletion_mode_btn_lavel": "Select and delete page",
     "cancel": "Cancel",
@@ -606,6 +609,7 @@
     }
   },
   "login": {
+    "title": "Login",
     "sign_in_error": "Login error",
     "registration_successful": "registration_successful. Please wait for administrator approval.",
     "Setup": "Setup",
@@ -613,6 +617,7 @@
     "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
   },
   "invited": {
+    "title": "Invited",
     "discription_heading": "Create Account",
     "discription": "Create an your account with the invited email address"
   },
@@ -810,5 +815,12 @@
     "answer": "Answer",
     "no_answer": "No answer",
     "settings": "Questionnaire settings"
+  },
+  "tag_edit_modal": {
+    "edit_tags": "Edit Tags",
+    "done": "Done",
+    "tags_input": {
+      "tag_name": "tag name"
+    }
   }
 }

+ 13 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -80,7 +80,9 @@
   "View diff": "差分を表示",
   "No diff": "差分なし",
   "User ID": "ユーザーID",
+  "User Settings": "ユーザー設定",
   "User Information": "ユーザー情報",
+  "User Activation": "ユーザーアクティベーション",
   "Basic Info": "ユーザーの基本情報",
   "Name": "名前",
   "Email": "メールアドレス",
@@ -165,6 +167,7 @@
     "no_page_list": "このページの配下にはページが存在しません。"
   },
   "installer": {
+    "title": "インストーラー",
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
     "initial_account_will_be_administrator_automatically": "初めに作成するアカウントは、自動的に管理者権限が付与されます",
@@ -550,6 +553,7 @@
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
   },
   "search_result": {
+    "title": "検索",
     "result_meta": "検索結果:",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "cancel": "キャンセル",
@@ -605,6 +609,7 @@
     }
   },
   "login": {
+    "title": "ログイン",
     "sign_in_error": "ログインエラー",
     "registration_successful": "登録が完了しました。管理者の承認をお待ちください。",
     "Setup": "セットアップ",
@@ -612,6 +617,7 @@
     "set_env_var_for_logs": "(ログを取得するためには、環境変数 <code>DEBUG=crowi:service:PassportService</code> を設定してください。)"
   },
   "invited": {
+    "title": "招待",
     "discription_heading": "アカウント作成",
     "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
   },
@@ -809,5 +815,12 @@
     "answer": "回答する",
     "no_answer": "無回答",
     "settings": "アンケート設定"
+  },
+  "tag_edit_modal": {
+    "edit_tags": "タグの編集",
+    "done": "完了",
+    "tags_input": {
+      "tag_name": "タグ名"
+    }
   }
 }

+ 12 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -86,6 +86,7 @@
 	"My Drafts": "My Drafts",
 	"User Settings": "用户设置",
 	"User Information": "用户信息",
+  "User Activation": "用户激活",
 	"Basic Info": "基础信息",
 	"Name": "姓名",
 	"Email": "邮箱",
@@ -171,6 +172,7 @@
     "no_page_list": "There are no pages under this page."
   },
 	"installer": {
+    "title": "安装",
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",
 		"initial_account_will_be_administrator_automatically": "初始帐户将自动成为管理员。",
@@ -555,6 +557,7 @@
     "link_sharing_is_disabled": "链接共享已被禁用"
   },
 	"search_result": {
+    "title": "搜索",
 		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"cancel": "取消",
@@ -610,6 +613,7 @@
     }
   },
 	"login": {
+    "title": "登录",
 		"sign_in_error": "登录错误",
 		"registration_successful": "注册成功。请等待管理员批准",
 		"Setup": "安装程序",
@@ -617,6 +621,7 @@
     "set_env_var_for_logs": "(请设置环境变量 <code>DEBUG=crowi:service:PassportService</code> 以获得日志。)"
 	},
   "invited": {
+    "invited": "邀请函",
     "discription_heading": "创建账户",
     "discription": "用被邀请的电子邮件地址创建一个你的账户"
   },
@@ -802,5 +807,12 @@
   "v5_page_migration": {
     "page_tree_not_avaliable": "Page Tree 功能不可用",
     "go_to_settings": "进入设置,启用该功能"
+  },
+  "tag_edit_modal": {
+    "edit_tags": "编辑标签",
+    "done": "完毕",
+    "tags_input": {
+      "tag_name": "标签名称"
+    }
   }
 }

+ 1 - 11
packages/app/resource/locales/en_US/sandbox.md

@@ -233,16 +233,6 @@ You can create links using `[Display text](URL)`.
 
 [Google](https://www.google.co.jp/)
 
-## Crowi compatibility
-
-```
-[/Sandbox]
-</user/admin1>
-```
-
-[/Sandbox]  
-</user/admin1>
-
 ## Pukiwiki like linker
 
 This is the most flexible linker.
@@ -254,7 +244,7 @@ Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 
 [[./Bootstrap4]]  
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 # :memo: Lists
 

+ 0 - 10
packages/app/resource/locales/ja_JP/sandbox.md

@@ -232,16 +232,6 @@ ___
 
 [Google](https://www.google.co.jp/)
 
-## Crowi 互換
-
-```
-[/Sandbox]
-</user/admin1>
-```
-
-[/Sandbox]  
-</user/admin1>
-
 ## Pukiwiki like linker
 
 最も柔軟な Linker です。

+ 1 - 11
packages/app/resource/locales/zh_CN/sandbox.md

@@ -233,16 +233,6 @@ You can create links using `[Display text](URL)`.
 
 [Google](https://www.google.co.jp/)
 
-## Crowi compatibility
-
-```
-[/Sandbox]
-</user/admin1>
-```
-
-[/Sandbox]  
-</user/admin1>
-
 ## Pukiwiki like linker
 
 This is the most flexible linker.
@@ -250,7 +240,7 @@ Both the page description and link address can be displayed on the page.
 
 ```
 [[./Bootstrap4]]
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 
 [[./Bootstrap4]]  

+ 2 - 3
packages/app/src/client/services/layout.ts

@@ -1,4 +1,4 @@
-import { useIsContainerFluid, useShareLinkId } from '~/stores/context';
+import { useIsContainerFluid } from '~/stores/context';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useEditorMode } from '~/stores/ui';
 
@@ -17,8 +17,7 @@ export const useEditorModeClassName = (): string => {
 };
 
 export const useCurrentGrowiLayoutFluidClassName = (): string => {
-  const { data: shareLinkId } = useShareLinkId();
-  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+  const { data: currentPage } = useSWRxCurrentPage();
 
   const { data: dataIsContainerFluid } = useIsContainerFluid();
 

+ 1 - 1
packages/app/src/components/Admin/AdminHome/SystemInfomationTable.tsx

@@ -25,7 +25,7 @@ const SystemInformationTable = (props: Props) => {
       <tbody>
         <tr>
           <th>GROWI</th>
-          <td data-hide-in-vrt>{ growiVersion }</td>
+          <td data-vrt-blackout>{ growiVersion }</td>
         </tr>
         <tr>
           <th>node.js</th>

+ 31 - 42
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -1,45 +1,34 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
-  apiv3Delete, apiv3Get, apiv3PostForm, apiv3Put,
+  apiv3Delete, apiv3PostForm, apiv3Put,
 } from '~/client/util/apiv3-client';
 import ImageCropModal from '~/components/Common/ImageCropModal';
+import { useIsDefaultLogo, useIsCustomizedLogoUploaded } from '~/stores/context';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
+
 const DEFAULT_LOGO = '/images/logo.svg';
+const CUSTOMIZED_LOGO = '/attachment/brand-logo';
 
 const CustomizeLogoSetting = (): JSX.Element => {
 
   const { t } = useTranslation();
+  const { data: isDefaultLogo } = useIsDefaultLogo();
+  const { data: isCustomizedLogoUploaded, mutate: mutateIsCustomizedLogoUploaded } = useIsCustomizedLogoUploaded();
 
   const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
   const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
-  const [isDefaultLogo, setIsDefaultLogo] = useState<boolean>(true);
+  const [isDefaultLogoSelected, setIsDefaultLogoSelected] = useState<boolean>(isDefaultLogo ?? true);
   const [retrieveError, setRetrieveError] = useState<any>();
-  const [customizedLogoSrc, setCustomizedLogoSrc] = useState< string | null >(null);
-
-  const retrieveData = useCallback(async() => {
-    try {
-      const response = await apiv3Get('/customize-setting/customize-logo');
-      const { isDefaultLogo: _isDefaultLogo, customizedLogoSrc } = response.data;
-      const isDefaultLogo = _isDefaultLogo ?? true;
-
-      setIsDefaultLogo(isDefaultLogo);
-      setCustomizedLogoSrc(customizedLogoSrc);
-    }
-    catch (err) {
-      setRetrieveError(err);
-      throw new Error('Failed to fetch data');
-    }
-  }, []);
 
-  useEffect(() => {
-    retrieveData();
-  }, [retrieveData]);
+  const currentLogo = useMemo(() => {
+    return isDefaultLogo ? DEFAULT_LOGO : CUSTOMIZED_LOGO;
+  }, [isDefaultLogo]);
 
   const onSelectFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
     if (e.target.files != null && e.target.files.length > 0) {
@@ -52,22 +41,18 @@ const CustomizeLogoSetting = (): JSX.Element => {
 
   const onClickSubmit = useCallback(async() => {
     try {
-      const response = await apiv3Put('/customize-setting/customize-logo', {
-        isDefaultLogo,
-      });
-      const { customizedParams } = response.data;
-      setIsDefaultLogo(customizedParams.isDefaultLogo);
+      await apiv3Put('/customize-setting/customize-logo', { isDefaultLogo: isDefaultLogoSelected });
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, isDefaultLogo]);
+  }, [t, isDefaultLogoSelected]);
 
   const onClickDeleteBtn = useCallback(async() => {
     try {
       await apiv3Delete('/customize-setting/delete-brand-logo');
-      setCustomizedLogoSrc(null);
+      mutateIsCustomizedLogoUploaded(false);
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
     }
     catch (err) {
@@ -75,15 +60,15 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to delete logo');
     }
-  }, [t]);
+  }, [mutateIsCustomizedLogoUploaded, t]);
 
 
   const processImageCompletedHandler = useCallback(async(croppedImage) => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
-      const { data } = await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
-      setCustomizedLogoSrc(data.attachment.filePathProxied);
+      await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
+      mutateIsCustomizedLogoUploaded(true);
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
     }
     catch (err) {
@@ -91,7 +76,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to upload brand logo');
     }
-  }, [t]);
+  }, [mutateIsCustomizedLogoUploaded, t]);
 
   return (
     <React.Fragment>
@@ -109,8 +94,8 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       className="custom-control-input"
                       form="formImageType"
                       name="imagetypeForm[isDefaultLogo]"
-                      checked={isDefaultLogo}
-                      onChange={() => { setIsDefaultLogo(true) }}
+                      checked={isDefaultLogoSelected}
+                      onChange={() => { setIsDefaultLogoSelected(true) }}
                     />
                     <label className="custom-control-label" htmlFor="radioDefaultLogo">
                       {t('admin:customize_settings.default_logo')}
@@ -128,8 +113,8 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       className="custom-control-input"
                       form="formImageType"
                       name="imagetypeForm[isDefaultLogo]"
-                      checked={!isDefaultLogo}
-                      onChange={() => { setIsDefaultLogo(false) }}
+                      checked={!isDefaultLogoSelected}
+                      onChange={() => { setIsDefaultLogoSelected(false) }}
                     />
                     <label className="custom-control-label" htmlFor="radioUploadLogo">
                       { t('admin:customize_settings.upload_logo') }
@@ -141,11 +126,15 @@ const CustomizeLogoSetting = (): JSX.Element => {
                     { t('admin:customize_settings.current_logo') }
                   </label>
                   <div className="col-sm-8 col-12">
-                    <p><img src={customizedLogoSrc || DEFAULT_LOGO} className="picture picture-lg " id="settingBrandLogo" width="64" /></p>
-                    {(customizedLogoSrc != null) && (
-                      <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
-                        { t('admin:customize_settings.delete_logo') }
-                      </button>
+                    {isCustomizedLogoUploaded && (
+                      <>
+                        <p>
+                          <img src='/attachment/brand-logo' className="picture picture-lg " id="settingBrandLogo" width="64" />
+                        </p>
+                        <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
+                          { t('admin:customize_settings.delete_logo') }
+                        </button>
+                      </>
                     )}
                   </div>
                 </div>

+ 3 - 4
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useState } from 'react';
 import { PresetThemes, PresetThemesMetadatas } from '@growi/preset-themes';
 import { useTranslation } from 'next-i18next';
 
-import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { useSWRxGrowiThemeSetting } from '~/stores/admin/customize';
 
@@ -20,7 +19,7 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data, error } = useSWRxGrowiThemeSetting();
+  const { data, error, update } = useSWRxGrowiThemeSetting();
   const [currentTheme, setCurrentTheme] = useState(data?.currentTheme);
 
   useEffect(() => {
@@ -38,7 +37,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     }
 
     try {
-      await apiv3Put('/customize-setting/theme', {
+      await update({
         theme: currentTheme,
       });
 
@@ -47,7 +46,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [currentTheme, t]);
+  }, [currentTheme, t, update]);
 
   const availableThemes = data?.pluginThemesMetadatas == null
     ? PresetThemesMetadatas

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -43,7 +43,7 @@ export const UpdateParentConfirmModal: FC = () => {
                 {t('admin:user_group_management.update_parent_confirm_modal.danger_message')}
               </div>
 
-              <div className="custom-control custom-checkbox custom-checkbox-primary pl-5">
+              <div className="custom-control custom-checkbox custom-checkbox-succsess pl-5">
                 <input
                   className="custom-control-input"
                   name="forceUpdateParents"

+ 33 - 1
packages/app/src/components/Fab.module.scss

@@ -1,9 +1,41 @@
+@use '~/styles/bootstrap/init' as bs;
+
 .grw-fab :global {
+  position: fixed;
+  right: 1.5rem;
+  bottom: 3rem;
+  z-index: bs.$zindex-fixed;
+
+  transition: all 200ms linear;
+
+  .btn-create-page {
+    width: 60px;
+    height: 60px;
+    font-size: 24px;
+
+    box-shadow: 2px 3px 6px #0000005d;
+    svg {
+      width: 28px;
+      height: 28px;
+    }
+  }
+
+  .btn-scroll-to-top {
+    width: 40px;
+    height: 40px;
+
+    opacity: 0.4;
+    svg {
+      width: 18px;
+      height: 18px;
+    }
+  }
+
   // workaround
   // https://stackoverflow.com/a/57667536
   .fadeInUp {
     & :local {
-      animation: fab-fadeinup 1s ease 0s;
+      animation: fab-fadeinup 0.5s ease 0s;
     }
   }
   .fadeOut {

+ 50 - 34
packages/app/src/components/Fab.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useCallback, useRef,
+  useState, useCallback, useRef, useEffect,
 } from 'react';
 
 import { animateScroll } from 'react-scroll';
@@ -7,9 +7,9 @@ import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
-import { useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
+import { useIsAbleToChangeEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { CreatePageIcon } from './Icons/CreatePageIcon';
@@ -21,45 +21,61 @@ const logger = loggerFactory('growi:cli:Fab');
 
 export const Fab = (): JSX.Element => {
 
-  const { data: currentUser } = useCurrentUser();
+  const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: currentPath = '' } = useCurrentPagePath();
   const { open: openCreateModal } = usePageCreateModal();
 
-  const [animateClasses, setAnimateClasses] = useState('invisible');
-  const [buttonClasses, setButtonClasses] = useState('');
+  const [animateClasses, setAnimateClasses] = useState<string>('invisible');
+  const [buttonClasses, setButtonClasses] = useState<string>('');
+  const [isSticky, setIsSticky] = useState<boolean>(false);
 
   // ripple
   const createBtnRef = useRef(null);
   useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
-  /*
-  * TODO: Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
-  *       We need add style={{ position: 'relative }} to child elements if disable StickyEvents. see: use grep = "<Fab".
-  */
-  // const stickyChangeHandler = useCallback((event) => {
-  //   logger.debug('StickyEvents.CHANGE detected');
-
-  //   const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
-  //   const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
-
-  //   setAnimateClasses(newAnimateClasses);
-  //   setButtonClasses(newButtonClasses);
-  // }, []);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyChangeHandler]);
+  /**
+   * After the fade animation is finished, fix the button display status.
+   * Prevents the fade animation occurred each time by button components rendered.
+   * Check Fab.module.scss for fade animation time.
+   */
+  useEffect(() => {
+    const timer = setTimeout(() => {
+      if (isSticky) {
+        setAnimateClasses('visible');
+        setButtonClasses('');
+      }
+      else {
+        setAnimateClasses('invisible');
+      }
+    }, 500);
+    return () => clearTimeout(timer);
+  }, [isSticky]);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+
+    const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+    const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
+
+    setAnimateClasses(newAnimateClasses);
+    setButtonClasses(newButtonClasses);
+    setIsSticky(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
 
   const PageCreateButton = useCallback(() => {
     return (
@@ -102,7 +118,7 @@ export const Fab = (): JSX.Element => {
 
   return (
     <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab-container">
-      {currentUser != null && <PageCreateButton />}
+      {isAbleToChangeEditorMode && <PageCreateButton />}
       <ScrollToTopButton />
     </div>
   );

+ 4 - 1
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -26,7 +26,10 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const limit = 6;
 
   const { data: socket } = useDefaultSocket();
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(limit);
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
+    limit, undefined, undefined,
+    { revalidateOnFocus: isOpen },
+  );
   const { data: inAppNotificationUnreadStatusCount, mutate: mutateInAppNotificationUnreadStatusCount } = useSWRxInAppNotificationStatus();
 
   // ripple

+ 1 - 1
packages/app/src/components/Layout/BasicLayout.tsx

@@ -43,7 +43,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
             <Sidebar />
           </div>
 
-          <div className="flex-fill mw-0" style={{ position: 'relative' }}>
+          <div className="flex-fill mw-0">
             <AlertSiteUrlUndefined />
             {children}
           </div>

+ 1 - 1
packages/app/src/components/Layout/MainPane.tsx

@@ -24,7 +24,7 @@ export const MainPane = (props: Props): JSX.Element => {
                   <div className="flex-grow-1 flex-basis-0 mw-0">
                     {children}
                   </div>
-                  <div className="grw-side-contents-container d-edit-none">
+                  <div className="grw-side-contents-container d-edit-none" data-vrt-blackout-side-contents>
                     <div className="grw-side-contents-sticky-container">
                       {sideContents}
                     </div>

+ 2 - 1
packages/app/src/components/Layout/RawLayout.tsx

@@ -1,10 +1,11 @@
 import React, { ReactNode, useState } from 'react';
 
+import { ColorScheme } from '@growi/core';
 import Head from 'next/head';
 import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
-import { ColorScheme, useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
+import { useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -28,7 +28,7 @@ export const ShareLinkLayout = ({ children }: Props): JSX.Element => {
       <GrowiNavbar isGlobalSearchHidden={true} />
 
       <div className="page-wrapper d-flex d-print-block">
-        <div className="flex-fill mw-0" style={{ position: 'relative' }}>
+        <div className="flex-fill mw-0">
           {children}
         </div>
       </div>

+ 1 - 1
packages/app/src/components/Me/ApiSettings.tsx

@@ -39,7 +39,7 @@ const ApiSettings = React.memo((): JSX.Element => {
             ? (
               <input
                 data-testid="grw-api-settings-input"
-                data-hide-in-vrt
+                data-vrt-blackout
                 className="form-control"
                 type="text"
                 name="apiToken"

+ 2 - 2
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -105,14 +105,14 @@ const ProfileImageSettings = (): JSX.Element => {
                 onChange={() => setGravatarEnabled(true)}
               />
               <label className="custom-control-label" htmlFor="radioGravatar">
-                <img src={GRAVATAR_DEFAULT} data-hide-in-vrt /> Gravatar
+                <img src={GRAVATAR_DEFAULT} data-vrt-blackout-profile /> Gravatar
               </label>
               <a href="https://gravatar.com/">
                 <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
               </a>
             </div>
           </h4>
-          <img src={generateGravatarSrc(currentUser.email)} width="64" data-hide-in-vrt />
+          <img src={generateGravatarSrc(currentUser.email)} width="64" data-vrt-blackout-profile />
         </div>
 
         <div className="col-md-6 col-12">

+ 3 - 3
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -47,15 +47,15 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
 
   const followOsCheckboxModifiedHandler = useCallback((isChecked: boolean) => {
     if (isChecked) {
-      setTheme(Themes.system);
+      setTheme(Themes.SYSTEM);
     }
     else {
-      setTheme(resolvedTheme ?? Themes.light);
+      setTheme(resolvedTheme ?? Themes.LIGHT);
     }
   }, [resolvedTheme, setTheme]);
 
   const userPreferenceSwitchModifiedHandler = useCallback((isDarkMode: boolean) => {
-    setTheme(isDarkMode ? Themes.dark : Themes.light);
+    setTheme(isDarkMode ? Themes.DARK : Themes.LIGHT);
   }, [setTheme]);
 
   /* eslint-disable react/prop-types */

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.tsx

@@ -66,7 +66,7 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
       </div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date" data-hide-in-vrt>
+        <div className="text-muted text-date" data-vrt-blackout-datetime>
           {renderParsedDate()}
         </div>
       </div>

+ 29 - 24
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,8 @@
 import React, { useState, useEffect, useCallback } from 'react';
 
-import { isPopulated, IUser, pagePathUtils } from '@growi/core';
+import {
+  isPopulated, IUser, pagePathUtils, IPagePopulatedToShowRevision,
+} from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
@@ -25,7 +27,7 @@ import {
 import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
+  useIsAbleToChangeEditorMode, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 
 import CreateTemplateModal from '../CreateTemplateModal';
@@ -182,16 +184,19 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
 };
 
 type GrowiContextualSubNavigationProps = {
+  currentPage?: IPagePopulatedToShowRevision | null,
   isCompactMode?: boolean,
   isLinkSharingDisabled: boolean,
 };
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
+  const { currentPage } = props;
+
   const router = useRouter();
 
   const { data: shareLinkId } = useShareLinkId();
-  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
 
   const { data: currentPathname } = useCurrentPathname();
   const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
@@ -211,10 +216,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
-  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(currentPage?._id);
 
   // eslint-disable-next-line max-len
   const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
@@ -314,9 +319,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   }, [currentPathname, openDeleteModal, router]);
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
-    await updateContentWidth(pageId, value);
-    mutateCurrentPage();
-  }, [mutateCurrentPage]);
+    if (!isSharedPage) {
+      await updateContentWidth(pageId, value);
+      mutateCurrentPage();
+    }
+  }, [isSharedPage, mutateCurrentPage]);
 
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
@@ -373,7 +380,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                 ) }
               </div>
             ) }
-            {isAbleToShowPageEditorModeManager && (
+            {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
                 isBtnDisabled={isGuestUser}
@@ -416,21 +423,19 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     : currentPage?.path;
 
   return (
-    <div data-testid="grw-contextual-sub-nav">
-      <GrowiSubNavigation
-        pagePath={pagePath}
-        pageId={currentPage?._id}
-        showDrawerToggler={isDrawerMode}
-        showTagLabel={isAbleToShowTagLabel}
-        isGuestUser={isGuestUser}
-        isDrawerMode={isDrawerMode}
-        isCompactMode={isCompactMode}
-        tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
-        tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-        rightComponent={RightComponent}
-        additionalClasses={['container-fluid']}
-      />
-    </div>
+    <GrowiSubNavigation
+      pagePath={pagePath}
+      pageId={currentPage?._id}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
+      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
+      rightComponent={RightComponent}
+      additionalClasses={['container-fluid']}
+    />
   );
 };
 

+ 8 - 8
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -11,7 +11,7 @@ import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import {
-  useIsSearchPage, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useCustomizedLogoSrc,
+  useIsSearchPage, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useIsDefaultLogo,
 } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
@@ -122,16 +122,16 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps): JSX
 Confidential.displayName = 'Confidential';
 
 interface NavbarLogoProps {
-  logoSrc?: string,
+  isDefaultLogo?: boolean
 }
 
 const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
-  const { logoSrc } = props;
+  const { isDefaultLogo } = props;
 
-  return logoSrc != null
+  return isDefaultLogo
+    ? <GrowiLogo />
     // eslint-disable-next-line @next/next/no-img-element
-    ? (<img src={logoSrc} alt="custom logo" className="picture picture-lg p-2 mx-2" id="settingBrandLogo" width="32" />)
-    : <GrowiLogo />;
+    : (<img src='/attachment/brand-logo' alt="custom logo" className="picture picture-lg p-2 mx-2" id="settingBrandLogo" width="32" />);
 });
 
 GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
@@ -151,7 +151,7 @@ export const GrowiNavbar = (props: Props): JSX.Element => {
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isSearchPage } = useIsSearchPage();
-  const { data: customizedLogoSrc } = useCustomizedLogoSrc();
+  const { data: isDefaultLogo } = useIsDefaultLogo();
 
   return (
     <nav id="grw-navbar" className={`navbar grw-navbar ${styles['grw-navbar']} navbar-expand navbar-dark sticky-top mb-0 px-0`}>
@@ -159,7 +159,7 @@ export const GrowiNavbar = (props: Props): JSX.Element => {
       <div className="navbar-brand mr-0">
         <Link href="/" prefetch={false}>
           <a className="grw-logo d-block">
-            <GrowiNavbarLogo logoSrc={customizedLogoSrc}/>
+            <GrowiNavbarLogo isDefaultLogo={isDefaultLogo} />
           </a>
         </Link>
       </div>

+ 0 - 9
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -172,12 +172,3 @@
     }
   }
 }
-
-/*
- * shadow
- */
-.grw-subnav-append-shadow-container {
-  .grw-subnav {
-    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
-  }
-}

+ 0 - 138
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,138 +0,0 @@
-import React, {
-  useMemo, useState, useRef, useEffect, useCallback,
-} from 'react';
-
-import PropTypes from 'prop-types';
-import StickyEvents from 'sticky-events';
-import { debounce } from 'throttle-debounce';
-
-import { useSidebarCollapsed } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
-
-import styles from './GrowiSubNavigationSwitcher.module.scss';
-
-const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
-
-
-/**
- * Subnavigation
- *
- * needs:
- *   #grw-subnav-fixed-container element
- *   #grw-subnav-sticky-trigger element
- *
- * @param {object} props
- */
-const GrowiSubNavigationSwitcher = (props) => {
-
-  const { data: isSidebarCollapsed } = useSidebarCollapsed();
-
-  const [isVisible, setVisible] = useState(false);
-  const [width, setWidth] = useState(null);
-
-  const fixedContainerRef = useRef();
-  /*
-  * Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
-  * The above err occurs when moving to admin page after rendering normal pages.
-  * This is because id "grw-subnav-sticky-trigger" does not exist on admin pages.
-  */
-  // const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
-
-  const initWidth = useCallback(() => {
-    const instance = fixedContainerRef.current;
-
-    if (instance == null || instance.parentNode == null) {
-      return;
-    }
-
-    // get parent width
-    const { clientWidth } = instance.parentNode;
-    // update style
-    setWidth(clientWidth);
-  }, []);
-
-  // const initVisible = useCallback(() => {
-  //   const elements = stickyEvents.stickyElements;
-
-  //   for (const elem of elements) {
-  //     const bool = stickyEvents.isSticking(elem);
-  //     if (bool) {
-  //       setVisible(bool);
-  //       break;
-  //     }
-  //   }
-
-  // }, [stickyEvents]);
-
-  // setup effect by resizing event
-  useEffect(() => {
-    const resizeHandler = debounce(100, initWidth);
-
-    window.addEventListener('resize', resizeHandler);
-
-    // return clean up handler
-    return () => {
-      window.removeEventListener('resize', resizeHandler);
-    };
-  }, [initWidth]);
-
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-    setVisible(event.detail.isSticky);
-  }, []);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyChangeHandler, stickyEvents]);
-
-  // update width when sidebar collapsing changed
-  useEffect(() => {
-    if (isSidebarCollapsed != null) {
-      setTimeout(initWidth, 300);
-    }
-  }, [isSidebarCollapsed, initWidth]);
-
-  // // initialize
-  // useEffect(() => {
-  //   initWidth();
-
-  //   // check sticky state several times
-  //   setTimeout(initVisible, 100);
-  //   setTimeout(initVisible, 300);
-  //   setTimeout(initVisible, 2000);
-
-  // }, [initWidth, initVisible]);
-
-  // ${styles['grw-subnav-switcher']}
-
-  return (
-    <div className={`${styles['grw-subnav-switcher']} ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div
-        id="grw-subnav-fixed-container"
-        className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}
-        ref={fixedContainerRef}
-        style={{ width }}
-      >
-        <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
-      </div>
-    </div>
-  );
-};
-
-GrowiSubNavigationSwitcher.propTypes = {
-  isLinkSharingDisabled: PropTypes.bool,
-};
-
-export default GrowiSubNavigationSwitcher;

+ 9 - 0
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.module.scss

@@ -19,6 +19,15 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     .grw-subnav-fixed-container {
       transition: transform 150ms $easeInOutCubic;
     }
+
+    /*
+    * shadow
+    */
+    .grw-subnav-append-shadow-container {
+      .grw-subnav {
+        box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
+      }
+    }
   }
 
   &:global {

+ 103 - 0
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx

@@ -0,0 +1,103 @@
+import React, {
+  useState, useRef, useEffect, useCallback,
+} from 'react';
+
+import StickyEvents from 'sticky-events';
+import { debounce } from 'throttle-debounce';
+
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useSidebarCollapsed } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
+
+import styles from './GrowiSubNavigationSwitcher.module.scss';
+
+const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
+
+/**
+ * GrowiSubNavigation
+ *
+ * needs:
+ *   #grw-subnav-fixed-container element
+ *   #grw-subnav-sticky-trigger element
+ */
+export const GrowiSubNavigationSwitcher = (): JSX.Element => {
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: isSidebarCollapsed } = useSidebarCollapsed();
+
+  const [isVisible, setIsVisible] = useState<boolean>(false);
+  const [width, setWidth] = useState<number>(0);
+
+  // use more specific type HTMLDivElement for avoid assertion error.
+  // see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement
+  const fixedContainerRef = useRef<HTMLDivElement>(null);
+
+  const initWidth = useCallback(() => {
+    if (fixedContainerRef.current && fixedContainerRef.current.parentElement) {
+      // get parent elements width
+      const { clientWidth } = fixedContainerRef.current.parentElement;
+      setWidth(clientWidth);
+    }
+  }, []);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+    setIsVisible(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky-events
+  useEffect(() => {
+    // sticky-events
+    // See: https://github.com/ryanwalters/sticky-events
+    const { stickySelector } = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = debounce(100, initWidth);
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [initWidth]);
+
+  // update width when sidebar collapsing changed
+  useEffect(() => {
+    if (isSidebarCollapsed != null) {
+      setTimeout(initWidth, 300);
+    }
+  }, [isSidebarCollapsed, initWidth]);
+
+  // initialize width
+  useEffect(() => {
+    initWidth();
+  }, [initWidth]);
+
+  if (currentPage == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`${styles['grw-subnav-switcher']} ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
+      <div
+        id="grw-subnav-fixed-container"
+        className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}
+        ref={fixedContainerRef}
+        style={{ width }}
+      >
+        <GrowiContextualSubNavigation currentPage={currentPage} isCompactMode isLinkSharingDisabled />
+      </div>
+    </div>
+  );
+};

+ 17 - 9
packages/app/src/components/Page.tsx

@@ -1,11 +1,11 @@
 import React, {
-  useCallback,
+  FC, useCallback,
   useEffect, useRef,
 } from 'react';
 
 import EventEmitter from 'events';
 
-import { pagePathUtils } from '@growi/core';
+import { pagePathUtils, IPagePopulatedToShowRevision } from '@growi/core';
 import { DrawioEditByViewerProps } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -48,9 +48,13 @@ const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr:
 
 const logger = loggerFactory('growi:Page');
 
+type Props = {
+  currentPage?: IPagePopulatedToShowRevision,
+}
 
-export const Page = (props) => {
+export const Page: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
+  const { currentPage } = props;
 
   // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
   // The toc node passed by customizeTOC is assigned to tocRef.current.
@@ -64,9 +68,9 @@ export const Page = (props) => {
   const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
 
   const { data: shareLinkId } = useShareLinkId();
-  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { data: tagsInfo } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);
@@ -128,14 +132,16 @@ export const Page = (props) => {
       toastSuccess(t('toaster.save_succeeded'));
 
       // rerender
-      mutateCurrentPage();
+      if (!isSharedPage) {
+        mutateCurrentPage();
+      }
       mutateEditingMarkdown(newMarkdown);
     }
     catch (error) {
       logger.error('failed to save', error);
       toastError(error);
     }
-  }, [currentPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
+  }, [currentPage, isSharedPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
 
   // set handler to open DrawioModal
   useEffect(() => {
@@ -182,14 +188,16 @@ export const Page = (props) => {
       toastSuccess(t('toaster.save_succeeded'));
 
       // rerender
-      mutateCurrentPage();
+      if (!isSharedPage) {
+        mutateCurrentPage();
+      }
       mutateEditingMarkdown(newMarkdown);
     }
     catch (error) {
       logger.error('failed to save', error);
       toastError(error);
     }
-  }, [currentPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
+  }, [currentPage, isSharedPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
 
   // set handler to open HandsonTableModal
   useEffect(() => {

+ 2 - 2
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -34,7 +34,7 @@ const PageView = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isNotFound } = useIsNotFound();
-  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+  const { data: currentPage } = useSWRxCurrentPage();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
 
   const { mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
@@ -92,7 +92,7 @@ const PageView = React.memo((): JSX.Element => {
   return (
     <>
       { isUsersHomePagePath && <UserInfo author={currentPage?.creator} /> }
-      { !isNotFound && <Page /> }
+      { !isNotFound && <Page currentPage={currentPage ?? undefined} /> }
       { isNotFound && <NotFoundPage /> }
     </>
   );

+ 5 - 3
packages/app/src/components/Page/TagEditModal.jsx

@@ -1,6 +1,7 @@
 import React, { useState, useEffect } from 'react';
-import PropTypes from 'prop-types';
 
+import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -9,6 +10,7 @@ import TagsInput from './TagsInput';
 
 function TagEditModal(props) {
   const [tags, setTags] = useState([]);
+  const { t } = useTranslation();
 
   function onTagsUpdatedByTagsInput(tags) {
     setTags(tags);
@@ -37,14 +39,14 @@ function TagEditModal(props) {
   return (
     <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
-        Edit Tags
+        {t('tag_edit_modal.edit_tags')}
       </ModalHeader>
       <ModalBody>
         <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} autoFocus />
       </ModalBody>
       <ModalFooter>
         <button type="button" className="btn btn-primary" onClick={handleSubmit}>
-          Done
+          {t('tag_edit_modal.done')}
         </button>
       </ModalFooter>
     </Modal>

+ 3 - 1
packages/app/src/components/Page/TagsInput.tsx

@@ -2,6 +2,7 @@ import React, {
   FC, useRef, useState, useCallback,
 } from 'react';
 
+import { useTranslation } from 'next-i18next';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 import { useSWRxTagsSearch } from '~/stores/tag';
@@ -20,6 +21,7 @@ type Props = {
 }
 
 const TagsInput: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
   const tagsInputRef = useRef<TypeaheadInstance>(null);
 
   const [resultTags, setResultTags] = useState<string[]>([]);
@@ -71,7 +73,7 @@ const TagsInput: FC<Props> = (props: Props) => {
         onSearch={searchHandler}
         onKeyDown={keyDownHandler}
         options={resultTags} // Search result (Some tag names)
-        placeholder="tag name"
+        placeholder={t('tag_edit_modal.tags_input.tag_name')}
         autoFocus={props.autoFocus}
       />
     </div>

+ 1 - 1
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -114,7 +114,7 @@ export const TrashPageAlert = (): JSX.Element => {
           <br />
           <UserPicture user={deleteUser} />
           <span className="ml-2">
-            Deleted by { deleteUser?.name } at {deletedAt || pageData?.updatedAt}
+            Deleted by { deleteUser?.name } at <span data-vrt-blackout-datetime>{deletedAt || pageData?.updatedAt}</span>
           </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">

+ 10 - 3
packages/app/src/components/PageEditor.tsx

@@ -2,8 +2,9 @@ import React, {
   useCallback, useEffect, useMemo, useRef, useState,
 } from 'react';
 
-
 import EventEmitter from 'events';
+import nodePath from 'path';
+
 
 import {
   IPageHasId, PageGrant, pathUtils,
@@ -31,6 +32,7 @@ import {
 } from '~/stores/editor';
 import { useConflictDiffModal } from '~/stores/modal';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { usePageTreeTermManager } from '~/stores/page-listing';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
@@ -88,6 +90,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
+  const { advance: advancePt } = usePageTreeTermManager();
 
   const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
@@ -104,7 +107,8 @@ const PageEditor = React.memo((): JSX.Element => {
 
     let initialValue = '';
     if (isEnabledAttachTitleHeader && currentPathname != null) {
-      initialValue += `${pathUtils.attachTitleHeader(currentPathname)}\n`;
+      const pageTitle = nodePath.basename(currentPathname);
+      initialValue += `${pathUtils.attachTitleHeader(pageTitle)}\n`;
     }
     if (templateBodyData != null) {
       initialValue += `${templateBodyData}\n`;
@@ -204,6 +208,9 @@ const PageEditor = React.memo((): JSX.Element => {
         options,
       );
 
+      // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
+      advancePt();
+
       return page;
     }
     catch (error) {
@@ -221,7 +228,7 @@ const PageEditor = React.memo((): JSX.Element => {
     }
 
   // eslint-disable-next-line max-len
-  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
+  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId, advancePt]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     if (editorMode !== EditorMode.Editor) {

+ 6 - 1
packages/app/src/components/PageEditor/DrawioModal.tsx

@@ -37,7 +37,12 @@ const drawioConfig = {
 
 export const DrawioModal = (): JSX.Element => {
   const { data: drawioUri } = useDrawioUri();
-  const { data: personalSettingsInfo } = usePersonalSettings();
+  const { data: personalSettingsInfo } = usePersonalSettings({
+    // make immutable
+    revalidateIfStale: false,
+    revalidateOnFocus: false,
+    revalidateOnReconnect: false,
+  });
 
   const { data: drawioModalData, close: closeDrawioModal } = useDrawioModal();
   const isOpened = drawioModalData?.isOpened ?? false;

+ 6 - 1
packages/app/src/components/PageEditor/ScrollSyncHelper.js

@@ -77,6 +77,11 @@ class ScrollSyncHelper {
     }
 
     const hiElement = lines[hi];
+
+    if (hiElement == null) {
+      return {};
+    }
+
     if (hi >= 1 && hiElement.element.getBoundingClientRect().top > position) {
       const loElement = lines[lo];
       const bounds = loElement.element.getBoundingClientRect();
@@ -95,7 +100,7 @@ class ScrollSyncHelper {
 
   getEditorLineNumberForPageOffset(parentElement, offset) {
     const { previous, next } = this.getLineElementsAtPageOffset(parentElement, offset);
-    if (previous) {
+    if (previous != null) {
       if (next) {
         const betweenProgress = (
           offset - parentElement.scrollTop - previous.element.getBoundingClientRect().top)

+ 12 - 3
packages/app/src/components/PageEditorByHackmd.tsx

@@ -25,6 +25,7 @@ import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { usePageTreeTermManager } from '~/stores/page-listing';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
   EditorMode,
@@ -63,6 +64,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: grantData } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const saveOrUpdate = useSaveOrUpdate();
+  const { advance: advancePt } = usePageTreeTermManager();
 
   const { returnPathForURL } = pathUtils;
 
@@ -127,6 +129,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       else {
         updateStateAfterSave?.();
         mutateIsHackmdDraftUpdatingInRealtime(false);
+
+        // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
+        advancePt();
       }
       setIsInitialized(false);
       mutateEditorMode(EditorMode.View);
@@ -136,7 +141,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
     }
   // eslint-disable-next-line max-len
-  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, isNotFound, mutateEditorMode, router, updateStateAfterSave, mutateIsHackmdDraftUpdatingInRealtime]);
+  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, isNotFound, mutateEditorMode, router, updateStateAfterSave, mutateIsHackmdDraftUpdatingInRealtime, advancePt]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -258,6 +263,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       updateStateAfterSave?.();
       mutateTagsInfo();
 
+      // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
+      advancePt();
+
       mutateIsEnabledUnsavedWarning(false);
 
       logger.debug('success to save');
@@ -268,8 +276,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave,
-      saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, mutateIsEnabledUnsavedWarning, t]);
+  }, [
+    currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave,
+    saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, advancePt, mutateIsEnabledUnsavedWarning, t]);
 
   /**
    * onChange event of HackmdEditor handler

+ 1 - 1
packages/app/src/components/PageRenameModal.tsx

@@ -304,7 +304,7 @@ const PageRenameModal = (): JSX.Element => {
             </label>
           </div>
 
-          <div className="custom-control custom-checkbox custom-checkbox-primary">
+          <div className="custom-control custom-checkbox custom-checkbox-success">
             <input
               className="custom-control-input"
               name="remain_metadata"

+ 1 - 1
packages/app/src/components/PrivateLegacyPages.tsx

@@ -29,7 +29,7 @@ import PaginationWrapper from './PaginationWrapper';
 import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
-import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
 
 
 // TODO: replace with "customize:showPageLimitationS"

+ 27 - 1
packages/app/src/components/Questionnaire/QuestionnaireModalManager.tsx

@@ -6,7 +6,33 @@ import QuestionnaireToast from './QuestionnaireToast';
 import styles from './QuestionnaireModalManager.module.scss';
 
 const QuestionnaireModalManager = ():JSX.Element => {
-  const { data: questionnaireOrders } = useSWRxQuestionnaireOrders();
+  // const { data: questionnaireOrders } = useSWRxQuestionnaireOrders();
+  const questionnaireOrders = [{
+    _id: '169321977921537921',
+    title: {
+      ja_JP: 'タイトル',
+      en_US: 'TITLE',
+    },
+    showFrom: new Date(),
+    showUntil: new Date(),
+    questions: [{
+      _id: '169321973r7921537921',
+      type: 'text',
+      text: {
+        ja_JP: 'GROWI どうすか?',
+        en_US: 'Hows GROWI',
+      },
+    }],
+    condition: {
+      user: {
+        types: ['admin', 'general'],
+      },
+      growi: {
+        types: ['oss'], // GROWI types to show questionnaire in
+        versionRegExps: ['adwwadwad'], // GROWI versions to show questionnaire in
+      },
+    },
+  }];
 
   return <>
     {questionnaireOrders?.map((questionnaireOrder) => {

+ 2 - 2
packages/app/src/components/SearchPage.tsx

@@ -16,7 +16,7 @@ import { NotAvailableForGuest } from './NotAvailableForGuest';
 import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
-import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
 
 
 // TODO: replace with "customize:showPageLimitationS"
@@ -63,7 +63,7 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
         <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
         { took != null && (
           // blackout 70px rectangle in VRT
-          <span data-hide-in-vrt className="ml-3 text-muted d-inline-block" style={{ minWidth: '70px' }}>({took}ms)</span>
+          <span data-vrt-blackout className="ml-3 text-muted d-inline-block" style={{ minWidth: '70px' }}>({took}ms)</span>
         ) }
       </div>
       <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">

+ 2 - 2
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -118,7 +118,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
             </div>
             <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
               <div className="border rounded px-2 py-1 mr-3">
-                <div className="custom-control custom-checkbox custom-checkbox-primary">
+                <div className="custom-control custom-checkbox custom-checkbox-succsess">
                   <input
                     className="custom-control-input mr-2"
                     type="checkbox"
@@ -132,7 +132,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
                 </div>
               </div>
               <div className="border rounded px-2 py-1">
-                <div className="custom-control custom-checkbox custom-checkbox-primary">
+                <div className="custom-control custom-checkbox custom-checkbox-succsess">
                   <input
                     className="custom-control-input mr-2"
                     type="checkbox"

+ 0 - 0
packages/app/src/components/SearchPage2/SearchPageBase.module.scss → packages/app/src/components/SearchPage/SearchPageBase.module.scss


+ 3 - 2
packages/app/src/components/SearchPage2/SearchPageBase.tsx → packages/app/src/components/SearchPage/SearchPageBase.tsx

@@ -14,7 +14,8 @@ import { usePageDeleteModal } from '~/stores/modal';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
-import { SearchResultList } from '../SearchPage/SearchResultList';
+
+import { SearchResultList } from './SearchResultList';
 
 import styles from './SearchPageBase.module.scss';
 
@@ -41,7 +42,7 @@ type Props = {
 }
 
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
-  const SearchResultContent = dynamic(import('../SearchPage/SearchResultContent').then(mod => mod.SearchResultContent), { ssr: false });
+  const SearchResultContent = dynamic(import('./SearchResultContent').then(mod => mod.SearchResultContent), { ssr: false });
   const {
     pages,
     searchingKeyword,

+ 8 - 0
packages/app/src/components/SearchPage/SearchResultContent.module.scss

@@ -0,0 +1,8 @@
+/*
+* shadow
+*/
+.grw-subnav-append-shadow-container :global {
+  .grw-subnav {
+    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
+  }
+}

+ 3 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -29,6 +29,8 @@ import { ROOT_ELEM_ID as RevisionLoaderRoomElemId, RevisionLoaderProps } from '.
 import { ROOT_ELEM_ID as PageCommentRootElemId, PageCommentProps } from '../PageComment';
 import { PageContentFooterProps } from '../PageContentFooter';
 
+import styles from './SearchResultContent.module.scss';
+
 
 const GrowiSubNavigation = dynamic<GrowiSubNavigationProps>(() => import('../Navbar/GrowiSubNavigation').then(mod => mod.GrowiSubNavigation), { ssr: false });
 const SubNavButtons = dynamic<SubNavButtonsProps>(() => import('../Navbar/SubNavButtons').then(mod => mod.SubNavButtons), { ssr: false });
@@ -242,7 +244,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
   return (
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
-      <div className="grw-subnav-append-shadow-container">
+      <div className={`${styles['grw-subnav-append-shadow-container']} grw-subnav-append-shadow-container`}>
         <GrowiSubNavigation
           pagePath={page.path}
           pageId={page._id}

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -42,7 +42,7 @@ const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
         <div className="icon-bubble mr-1 d-inline-block"></div>
         <div className="mr-2 grw-list-counts d-inline-block">{page.commentCount}</div>
       </div>
-      <div className="grw-formatted-distance-date small mt-auto" data-hide-in-vrt>
+      <div className="grw-formatted-distance-date small mt-auto" data-vrt-blackout-datetime>
         <FormattedDistanceDate id={page._id} date={page.updatedAt} />
       </div>
     </div>

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -85,7 +85,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
   const { onItemSelected } = props;
 
   return (
-    <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`}>
+    <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`} data-vrt-blackout-sidebar-nav>
       <div className="grw-sidebar-nav-primary-container">
         {/* eslint-disable max-len */}
         <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />

+ 20 - 20
packages/app/src/components/StickyStretchableScroller.tsx

@@ -69,26 +69,26 @@ export const StickyStretchableScroller: FC<StickyStretchableScrollerProps> = (pr
 
   const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
 
-  // const stickyChangeHandler = useCallback(() => {
-  //   logger.debug('StickyEvents.CHANGE detected');
-  //   resetScrollbarDebounced();
-  // }, [resetScrollbarDebounced]);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
-  //   stickyEvents.enableEvents();
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyElemSelector, stickyChangeHandler]);
+  const stickyChangeHandler = useCallback(() => {
+    logger.debug('StickyEvents.CHANGE detected');
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
+    stickyEvents.enableEvents();
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyElemSelector, stickyChangeHandler]);
 
   // setup effect by resizing event
   useEffect(() => {

+ 1 - 1
packages/app/src/components/User/UserDate.jsx

@@ -16,7 +16,7 @@ export default class UserDate extends React.Component {
     const dt = format(date, this.props.format);
 
     return (
-      <span className={this.props.className} data-hide-in-vrt>
+      <span className={this.props.className} data-vrt-blackout-datetime>
         {dt}
       </span>
     );

+ 1 - 1
packages/app/src/interfaces/customize.ts

@@ -1,4 +1,4 @@
-import { GrowiThemeMetadata } from '@growi/core';
+import type { GrowiThemeMetadata } from '@growi/core';
 
 export type IResLayoutSetting = {
   isContainerFluid: boolean,

+ 48 - 8
packages/app/src/pages/[[...path]].page.tsx

@@ -33,6 +33,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 import type { EditorConfig } from '~/interfaces/editor-settings';
+import { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -57,7 +58,7 @@ import loggerFactory from '~/utils/logger';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
 import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
 import { BasicLayoutWithEditorMode } from '../components/Layout/BasicLayout';
-import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
+import GrowiContextualSubNavigationSubstance from '../components/Navbar/GrowiContextualSubNavigation';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
@@ -72,12 +73,12 @@ import {
   useIsAclEnabled, useIsSearchPage, useTemplateTagData, useTemplateBodyData, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid, useIsNotCreatable,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useIsContainerFluid, useIsNotCreatable,
 } from '../stores/context';
 
 import { NextPageWithLayout } from './_app.page';
 import {
-  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage,
 } from './utils/commons';
 
 
@@ -91,7 +92,8 @@ const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').
 const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('../components/PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
-const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
+const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher')
+  .then(mod => mod.GrowiSubNavigationSwitcher), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
@@ -132,6 +134,20 @@ superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMet
   'IPageToShowRevisionWithMetaTransformer',
 );
 
+// GrowiContextualSubNavigation for NOT shared page
+type GrowiContextualSubNavigationProps = {
+  isLinkSharingDisabled: boolean,
+}
+
+const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
+  const { isLinkSharingDisabled } = props;
+  const { data: currentPage } = useSWRxCurrentPage();
+  return (
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiContextualSubNavigationSubstance currentPage={currentPage} isLinkSharingDisabled={isLinkSharingDisabled}/>
+    </div>
+  );
+};
 
 const IdenticalPathPage = (): JSX.Element => {
   const IdenticalPathPage = dynamic(() => import('../components/IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
@@ -185,6 +201,8 @@ type Props = CommonProps & {
   isIndentSizeForced: boolean,
   disableLinkSharing: boolean,
 
+  grantData?: IPageGrantData,
+
   rendererConfig: RendererConfig,
 
   // UI
@@ -266,7 +284,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd ?? false);
   useCurrentPathname(props.currentPathname);
 
-  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
   useEditingMarkdown(pageWithMeta?.data.revision?.body);
 
@@ -284,8 +302,9 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   // sync grant data
   useEffect(() => {
-    mutateSelectedGrant(grantData?.grantData.currentPageGrant);
-  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant]);
+    const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;
+    mutateSelectedGrant(grantDataToApply);
+  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant, props.grantData]);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
@@ -298,7 +317,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   const isTopPagePath = isTopPage(pageWithMeta?.data.path ?? '');
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitleForPage(props, pagePath ?? '');
 
 
   const sideContents = !props.isNotFound && !props.isNotCreatable
@@ -394,6 +413,21 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 }
 
+// apply parent page grant fot creating page
+async function applyGrantToPage(props: Props, ancestor: any) {
+  await ancestor.populate('grantedGroup');
+  const grant = {
+    grant: ancestor.grant,
+  };
+  const grantedGroup = ancestor.grantedGroup ? {
+    grantedGroup: {
+      id: ancestor.grantedGroup.id,
+      name: ancestor.grantedGroup.name,
+    },
+  } : {};
+  props.grantData = Object.assign(grant, grantedGroup);
+}
+
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const { model: mongooseModel } = await import('mongoose');
 
@@ -451,6 +485,12 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       props.templateTagData = templateData.templateTags as string[];
       props.templateBodyData = templateData.templateBody as string;
     }
+
+    // apply pagrent page grant
+    const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
+    if (ancestor != null) {
+      await applyGrantToPage(props, ancestor);
+    }
   }
 
   props.pageWithMeta = pageWithMeta;

+ 6 - 3
packages/app/src/pages/_app.page.tsx

@@ -11,7 +11,7 @@ import * as nextI18nConfig from '^/config/next-i18next.config';
 import { ActivatePluginService } from '~/client/services/activate-plugin';
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
-  useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
+  useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useIsDefaultLogo, useForcedColorScheme,
 } from '~/stores/context';
 import { SWRConfigValue, swrGlobalConfiguration } from '~/utils/swr-utils';
 
@@ -20,7 +20,9 @@ import { CommonProps } from './utils/commons';
 import { registerTransformerForObjectId } from './utils/objectid-transformer';
 
 import '~/styles/style-app.scss';
-
+import '~/styles/theme/_apply-colors-light.scss';
+import '~/styles/theme/_apply-colors-dark.scss';
+import '~/styles/theme/_apply-colors.scss';
 
 const isDev = process.env.NODE_ENV === 'development';
 
@@ -63,7 +65,8 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   useSiteUrl(commonPageProps.siteUrl);
   useConfidential(commonPageProps.confidential);
   useGrowiVersion(commonPageProps.growiVersion);
-  useCustomizedLogoSrc(commonPageProps.customizedLogoSrc);
+  useIsDefaultLogo(commonPageProps.isDefaultLogo);
+  useForcedColorScheme(commonPageProps.forcedColorScheme);
 
   // Use the layout defined at the page level, if available
   const getLayout = Component.getLayout ?? (page => page);

+ 6 - 51
packages/app/src/pages/_document.page.tsx

@@ -1,8 +1,6 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
-import type { ViteManifest } from '@growi/core';
-import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import Document, {
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
@@ -14,40 +12,6 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:page:_document');
 
-type HeadersForThemesProps = {
-  theme: string,
-  presetThemesManifest: ViteManifest,
-  pluginThemeHref: string | undefined,
-}
-const HeadersForThemes = (props: HeadersForThemesProps): JSX.Element => {
-  const {
-    theme, presetThemesManifest, pluginThemeHref,
-  } = props;
-
-  const elements: JSX.Element[] = [];
-
-  // when plugin theme is specified
-  if (pluginThemeHref != null) {
-    elements.push(
-      <link rel="stylesheet" key={`link_custom-themes-${theme}`} href={pluginThemeHref} />,
-    );
-  }
-  // preset theme
-  else {
-    const themeMetadata = PresetThemesMetadatas.find(p => p.name === theme);
-    const manifestKey = themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
-    if (themeMetadata == null || !(themeMetadata.manifestKey in presetThemesManifest)) {
-      logger.warn(`Use default theme because the key for '${theme} does not exist in preset-themes manifest`);
-    }
-    const href = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
-    elements.push(
-      <link rel="stylesheet" key={`link_preset-themes-${theme}`} href={href} />,
-    );
-  }
-
-  return <>{elements}</>;
-};
-
 type HeadersForGrowiPluginProps = {
   pluginResourceEntries: GrowiPluginResourceEntries;
 }
@@ -72,12 +36,10 @@ const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element =
 };
 
 interface GrowiDocumentProps {
-  theme: string,
+  themeHref: string,
   customScript: string | null,
   customCss: string | null,
   customNoscript: string | null,
-  presetThemesManifest: ViteManifest,
-  pluginThemeHref: string | undefined,
   pluginResourceEntries: GrowiPluginResourceEntries;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
@@ -87,28 +49,22 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { configManager, customizeService, pluginService } = crowi;
+    const { customizeService, pluginService } = crowi;
 
-    const theme = configManager.getConfig('crowi', 'customize:theme');
+    const { themeHref } = customizeService;
     const customScript: string | null = customizeService.getCustomScript();
     const customCss: string | null = customizeService.getCustomCss();
     const customNoscript: string | null = customizeService.getCustomNoscript();
 
-    // import preset-themes manifest
-    const presetThemesManifest = await import('@growi/preset-themes/dist/themes/manifest.json').then(imported => imported.default);
-
     // retrieve plugin manifests
     const pluginResourceEntries = await (pluginService as IPluginService).retrieveAllPluginResourceEntries();
-    const pluginThemeHref = await (pluginService as IPluginService).retrieveThemeHref(theme);
 
     return {
       ...initialProps,
-      theme,
+      themeHref,
       customScript,
       customCss,
       customNoscript,
-      presetThemesManifest,
-      pluginThemeHref,
       pluginResourceEntries,
     };
   }
@@ -137,7 +93,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   override render(): JSX.Element {
     const {
       customCss, customScript, customNoscript,
-      theme, presetThemesManifest, pluginThemeHref, pluginResourceEntries,
+      themeHref, pluginResourceEntries,
     } = this.props;
 
     return (
@@ -150,8 +106,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
-          <HeadersForThemes theme={theme}
-            presetThemesManifest={presetThemesManifest} pluginThemeHref={pluginThemeHref} />
+          <link rel="stylesheet" key="link-theme" href={themeHref} />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
           {this.renderCustomCss(customCss)}
         </Head>

+ 4 - 1
packages/app/src/pages/_search.page.tsx

@@ -5,6 +5,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
+import { useTranslation } from 'next-i18next';
 import SearchResultLayout from '~/components/Layout/SearchResultLayout';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
@@ -57,6 +58,8 @@ type Props = CommonProps & {
 const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   const { userUISettings } = props;
 
+  const { t } = useTranslation();
+
   // commons
   useCsrfToken(props.csrfToken);
 
@@ -88,7 +91,7 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
     return <PutbackPageModal />;
   };
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitle(props, t('search_result.title'));
 
   return (
     <>

+ 1 - 7
packages/app/src/pages/admin/[...path].page.tsx

@@ -2,9 +2,8 @@ import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import dynamic from 'next/dynamic';
-import Head from 'next/head';
 
-import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
+import { CommonProps } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
@@ -18,13 +17,8 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
 
-  const title = generateCustomTitle(props, 'GROWI');
-
   return (
     <AdminLayout>
-      <Head>
-        <title>{title}</title>
-      </Head>
       <AdminNotFoundPage />
     </AdminLayout>
   );

+ 4 - 1
packages/app/src/pages/admin/customize.page.tsx

@@ -10,7 +10,7 @@ import { Container, Provider } from 'unstated';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
-import { useCustomizeTitle, useCurrentUser } from '~/stores/context';
+import { useCustomizeTitle, useCurrentUser, useIsCustomizedLogoUploaded } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -20,6 +20,7 @@ const CustomizeSettingContents = dynamic(() => import('~/components/Admin/Custom
 
 type Props = CommonProps & {
   customizeTitle: string,
+  isCustomizedLogoUploaded: boolean,
 };
 
 
@@ -27,6 +28,7 @@ const AdminCustomizeSettingsPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
   useCustomizeTitle(props.customizeTitle);
   useCurrentUser(props.currentUser ?? null);
+  useIsCustomizedLogoUploaded(props.isCustomizedLogoUploaded);
 
   const componentTitle = t('customize_settings.customize_settings');
   const pageTitle = generateCustomTitle(props, componentTitle);
@@ -57,6 +59,7 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const { crowi } = req;
 
   props.customizeTitle = crowi.configManager.getConfig('crowi', 'customize:title');
+  props.isCustomizedLogoUploaded = await crowi.attachmentService.isBrandLogoExist();
 };
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

+ 2 - 1
packages/app/src/pages/admin/index.page.tsx

@@ -35,7 +35,8 @@ const AdminHomePage: NextPage<Props> = (props) => {
 
   const { t } = useTranslation('admin');
 
-  const title = t('wiki_management_home_page');
+  const title = generateCustomTitle(props, t('wiki_management_home_page'));
+
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {

+ 3 - 1
packages/app/src/pages/installer.page.tsx

@@ -4,6 +4,7 @@ import { pagePathUtils } from '@growi/core';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
+import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import Head from 'next/head';
 
@@ -33,6 +34,7 @@ type Props = CommonProps & {
 };
 
 const InstallerPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
 
   // commons
   useAppTitle(props.appTitle);
@@ -40,7 +42,7 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitle(props, t('installer.title'));
   const classNames: string[] = [];
 
   return (

+ 3 - 1
packages/app/src/pages/invited.page.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 import type { IUserHasId, IUser } from '@growi/core';
 import { USER_STATUS } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
@@ -26,12 +27,13 @@ type Props = CommonProps & {
 }
 
 const InvitedPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
 
   useCsrfToken(props.csrfToken);
   useCurrentPathname(props.currentPathname);
   useCurrentUser(props.currentUser);
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitle(props, t('invited.title'));
   const classNames: string[] = ['invited-page'];
 
   return (

+ 3 - 2
packages/app/src/pages/login/index.page.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
+import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import Head from 'next/head';
 
@@ -19,7 +20,6 @@ import {
   useCurrentPathname,
 } from '~/stores/context';
 
-
 import styles from './index.module.scss';
 
 
@@ -38,6 +38,7 @@ type Props = CommonProps & {
 };
 
 const LoginPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
 
   // commons
   useCsrfToken(props.csrfToken);
@@ -45,7 +46,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
   // page
   useCurrentPathname(props.currentPathname);
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitle(props, t('login.title'));
   const classNames: string[] = ['login-page', styles['login-page']];
 
   return (

+ 1 - 1
packages/app/src/pages/me/[[...path]].page.tsx

@@ -111,7 +111,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
 
   useRendererConfig(props.rendererConfig);
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitle(props, targetPage.title);
 
   return (
     <>

+ 46 - 9
packages/app/src/pages/share/[[...path]].page.tsx

@@ -1,17 +1,18 @@
 import React from 'react';
 
-import { IUserHasId } from '@growi/core';
+import { IUserHasId, IPagePopulatedToShowRevision } from '@growi/core';
 import {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
+import superjson from 'superjson';
 
 import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { MainPane } from '~/components/Layout/MainPane';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
-import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
+import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
 import type { PageSideContentsProps } from '~/components/PageSideContents';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
@@ -19,15 +20,16 @@ import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
+import type { PageDocument } from '~/server/models/page';
 import {
-  useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
+  useCurrentUser, useCurrentPageId, useRendererConfig, useIsSearchPage,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri, useIsContainerFluid,
 } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
 import { NextPageWithLayout } from '../_app.page';
 import {
-  CommonProps, getServerSideCommonProps, generateCustomTitle, getNextI18NextConfig,
+  CommonProps, getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig,
 } from '../utils/commons';
 
 const logger = loggerFactory('growi:next-page:share');
@@ -38,6 +40,7 @@ const ShareLinkAlert = dynamic(() => import('~/components/Page/ShareLinkAlert'),
 const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr: false });
 
 type Props = CommonProps & {
+  shareLinkRelatedPage?: IShareLinkRelatedPage,
   shareLink?: IShareLinkHasId,
   isExpired: boolean,
   disableLinkSharing: boolean,
@@ -48,12 +51,45 @@ type Props = CommonProps & {
   rendererConfig: RendererConfig,
 };
 
+type IShareLinkRelatedPage = IPagePopulatedToShowRevision & PageDocument;
+
+superjson.registerCustom<IShareLinkRelatedPage, string>(
+  {
+    isApplicable: (v): v is IShareLinkRelatedPage => {
+      return v != null
+        && v.toObject != null
+        && v.lastUpdateUser != null
+        && v.creator != null
+        && v.revision != null;
+    },
+    serialize: (v) => { return superjson.stringify(v.toObject()) },
+    deserialize: (v) => { return superjson.parse(v) },
+  },
+  'IShareLinkRelatedPageTransformer',
+);
+
+// GrowiContextualSubNavigation for shared page
+// get page info from props not to send request 'GET /page' from client
+type GrowiContextualSubNavigationForSharedPageProps = {
+  currentPage?: IPagePopulatedToShowRevision,
+  isLinkSharingDisabled: boolean,
+}
+
+const GrowiContextualSubNavigationForSharedPage = (props: GrowiContextualSubNavigationForSharedPageProps): JSX.Element => {
+  const { currentPage, isLinkSharingDisabled } = props;
+  if (currentPage == null) { return <></> }
+  return (
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiContextualSubNavigationSubstance currentPage={currentPage} isLinkSharingDisabled={isLinkSharingDisabled}/>
+    </div>
+  );
+};
+
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchPage(false);
   useShareLinkId(props.shareLink?._id);
   useCurrentPageId(props.shareLink?.relatedPage._id);
   useCurrentUser(props.currentUser);
-  useCurrentPathname(props.currentPathname);
   useRendererConfig(props.rendererConfig);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -68,7 +104,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   const isShowSharedPage = !props.disableLinkSharing && !isNotFound && !props.isExpired;
   const shareLink = props.shareLink;
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitleForPage(props, props.shareLinkRelatedPage?.path ?? '');
 
 
   const sideContents = shareLink != null
@@ -91,7 +127,8 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
         <header className="py-0 position-relative">
-          {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
+          {isShowSharedPage
+          && <GrowiContextualSubNavigationForSharedPage currentPage={props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />}
         </header>
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
@@ -128,7 +165,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
           {(isShowSharedPage && shareLink != null) && (
             <>
               <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-              <Page />
+              <Page currentPage={props.shareLinkRelatedPage} />
             </>
           )}
         </MainPane>
@@ -227,7 +264,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     const ShareLinkModel = crowi.model('ShareLink');
     const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');
     if (shareLink != null) {
-      await shareLink.relatedPage.populateDataToShowRevision();
+      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision();
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
     }

+ 1 - 1
packages/app/src/pages/tags.page.tsx

@@ -80,7 +80,7 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
 
   useRendererConfig(props.rendererConfig);
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitle(props, t('Tags'));
 
   return (
     <>

+ 5 - 2
packages/app/src/pages/trash.page.tsx

@@ -26,8 +26,9 @@ import {
 
 import { NextPageWithLayout } from './_app.page';
 import {
-  CommonProps, getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle,
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig, generateCustomTitleForPage,
 } from './utils/commons';
+import { useTranslation } from 'next-i18next';
 
 const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
 const EmptyTrashModal = dynamic(() => import('~/components/EmptyTrashModal'), { ssr: false });
@@ -70,12 +71,14 @@ const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
 
   useRendererConfig(props.rendererConfig);
 
+  const { t } = useTranslation();
+
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isGuestUser } = useIsGuestUser();
 
   const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName();
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitleForPage(props, '/trash');
 
   return (
     <>

+ 3 - 1
packages/app/src/pages/user-activation.page.tsx

@@ -1,4 +1,5 @@
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import Head from 'next/head';
 
@@ -22,8 +23,9 @@ type Props = CommonProps & {
 }
 
 const UserActivationPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
 
-  const title = generateCustomTitle(props, 'GROWI');
+  const title = generateCustomTitle(props, t('User Activation'));
 
   return (
     <NoLoginLayout>

+ 11 - 8
packages/app/src/pages/utils/commons.ts

@@ -1,12 +1,13 @@
+import type { ColorScheme, IUser, IUserHasId } from '@growi/core';
 import {
-  DevidedPagePath, Lang, AllLang, IUser, IUserHasId,
+  DevidedPagePath, Lang, AllLang,
 } from '@growi/core';
 import { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { SSRConfig, UserConfig } from 'next-i18next';
 
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
-import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 
 export type CommonProps = {
   namespacesRequired: string[], // i18next
@@ -20,8 +21,9 @@ export type CommonProps = {
   growiVersion: string,
   isMaintenanceMode: boolean,
   redirectDestination: string | null,
-  customizedLogoSrc?: string,
+  isDefaultLogo: boolean,
   currentUser?: IUser,
+  forcedColorScheme?: ColorScheme,
 } & Partial<SSRConfig>;
 
 // eslint-disable-next-line max-len
@@ -30,7 +32,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   const req = context.req as CrowiRequest<IUserHasId & any>;
   const { crowi, user } = req;
   const {
-    appService, configManager, customizeService,
+    appService, configManager, customizeService, attachmentService,
   } = crowi;
 
   const url = new URL(context.resolvedUrl, 'http://example.com');
@@ -45,7 +47,9 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
 
   // eslint-disable-next-line max-len, no-nested-ternary
   const redirectDestination = !isMaintenanceMode && currentPathname === '/maintenance' ? '/' : isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance') ? '/maintenance' : null;
-  const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
+  const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
+  const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo') || !isCustomizedLogoUploaded;
+  const forcedColorScheme = crowi.customizeService.forcedColorScheme;
 
   const props: CommonProps = {
     namespacesRequired: ['translation'],
@@ -59,8 +63,9 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     growiVersion: crowi.version,
     isMaintenanceMode,
     redirectDestination,
-    customizedLogoSrc: isDefaultLogo ? null : configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
     currentUser,
+    isDefaultLogo,
+    forcedColorScheme,
   };
 
   return { props };
@@ -104,7 +109,6 @@ export const getNextI18NextConfig = async(
 export const generateCustomTitle = (props: CommonProps, title: string): string => {
   return props.customTitleTemplate
     .replace('{{sitename}}', props.appTitle)
-    .replace('{{page}}', title)
     .replace('{{pagepath}}', title)
     .replace('{{pagename}}', title);
 };
@@ -120,6 +124,5 @@ export const generateCustomTitleForPage = (props: CommonProps, pagePath: string)
   return props.customTitleTemplate
     .replace('{{sitename}}', props.appTitle)
     .replace('{{pagepath}}', pagePath)
-    .replace('{{page}}', dPagePath.latter) // for backward compatibility
     .replace('{{pagename}}', dPagePath.latter);
 };

+ 2 - 1
packages/app/src/server/crowi/index.js

@@ -134,7 +134,6 @@ Crowi.prototype.init = async function() {
     this.setUpFileUploaderSwitchService(),
     this.setupAttachmentService(),
     this.setUpAcl(),
-    this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
     this.setupUserGroupService(),
     this.setupExport(),
@@ -145,6 +144,7 @@ Crowi.prototype.init = async function() {
     this.setupActivityService(),
     this.setupCommentService(),
     this.setupSyncPageStatusService(),
+    this.setUpCustomize(), // depends on pluginService
   ]);
 
   // globalNotification depends on slack and mailer
@@ -614,6 +614,7 @@ Crowi.prototype.setUpCustomize = async function() {
     this.customizeService = new CustomizeService(this);
     this.customizeService.initCustomCss();
     this.customizeService.initCustomTitle();
+    this.customizeService.initGrowiTheme();
 
     // add as a message handler
     if (this.s2sMessagingService != null) {

+ 8 - 0
packages/app/src/server/middlewares/certify-brand-logo.ts

@@ -0,0 +1,8 @@
+export const generateCertifyBrandLogoMiddleware = (crowi) => {
+
+  return async(req, res, next) => {
+    req.isBrandLogo = true;
+    next();
+  };
+
+};

+ 6 - 0
packages/app/src/server/middlewares/login-required.js

@@ -43,6 +43,12 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
       return next();
     }
 
+    // Check if it is a Brand logo
+    if (req.isBrandLogo) {
+      logger.debug('Target is Brand logo');
+      return next();
+    }
+
     // is api path
     const baseUrl = req.baseUrl || '';
     if (baseUrl.match(/^\/_api\/.+$/)) {

+ 1 - 4
packages/app/src/server/models/config.ts

@@ -116,6 +116,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:highlightJsStyle' : 'github',
   'customize:highlightJsStyleBorder' : false,
   'customize:theme' : PresetThemes.DEFAULT,
+  'customize:theme:forcedColorScheme' : null,
   'customize:isContainerFluid' : false,
   'customize:isEnabledTimeline' : true,
   'customize:isEnabledAttachTitleHeader' : false,
@@ -229,7 +230,6 @@ schema.statics.getLocalconfig = function(crowi) {
       DRAWIO_URI: env.DRAWIO_URI || null,
       HACKMD_URI: env.HACKMD_URI || null,
       MATHJAX: env.MATHJAX || null,
-      NO_CDN: env.NO_CDN || null,
       GROWI_CLOUD_URI: env.GROWI_CLOUD_URI || null,
       GROWI_APP_ID_FOR_GROWI_CLOUD: env.GROWI_APP_ID_FOR_GROWI_CLOUD || null,
     },
@@ -241,9 +241,6 @@ schema.statics.getLocalconfig = function(crowi) {
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
-    customizedLogoSrc: isDefaultLogo != null && !isDefaultLogo
-      ? crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc')
-      : null,
     auditLogEnabled: crowi.configManager.getConfig('crowi', 'app:auditLogEnabled'),
     activityExpirationSeconds: crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds'),
     auditLogAvailableActions: crowi.activityService.getAvailableActions(false),

+ 1 - 14
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -325,6 +325,7 @@ module.exports = (crowi) => {
       const customizedParams = {
         theme: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       };
+      customizeService.initGrowiTheme();
       const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
@@ -660,12 +661,6 @@ module.exports = (crowi) => {
     }
   });
 
-  router.get('/customize-logo', loginRequiredStrictly, adminRequired, async(req, res) => {
-    const isDefaultLogo = await crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
-    const customizedLogoSrc = await crowi.configManager.getConfig('crowi', 'customize:customizedLogoSrc');
-    return res.apiv3({ isDefaultLogo, customizedLogoSrc });
-  });
-
   router.put('/customize-logo', loginRequiredStrictly, adminRequired, validator.logo, apiV3FormValidator, async(req, res) => {
 
     const {
@@ -717,11 +712,6 @@ module.exports = (crowi) => {
       let attachment;
       try {
         attachment = await attachmentService.createAttachment(file, req.user, null, AttachmentType.BRAND_LOGO);
-        const attachmentConfigParams = {
-          'customize:customizedLogoSrc': attachment.filePathProxied,
-        };
-
-        await crowi.configManager.updateConfigsInTheSameNamespace('crowi', attachmentConfigParams);
       }
       catch (err) {
         logger.error(err);
@@ -741,9 +731,6 @@ module.exports = (crowi) => {
 
     try {
       await attachmentService.removeAllAttachments(attachments);
-      // update attachmentId immediately
-      const attachmentConfigParams = { 'customize:customizedLogoSrc': null };
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', attachmentConfigParams);
     }
     catch (err) {
       logger.error(err);

+ 10 - 0
packages/app/src/server/routes/attachment.js

@@ -296,6 +296,16 @@ module.exports = function(crowi, app) {
     return responseForAttachment(req, res, attachment);
   };
 
+  api.getBrandLogo = async function(req, res) {
+    const brandLogoAttachment = await Attachment.findOne({ attachmentType: AttachmentType.BRAND_LOGO });
+
+    if (brandLogoAttachment == null) {
+      return res.status(404).json(ApiResponse.error('Brand logo does not exist'));
+    }
+
+    return responseForAttachment(req, res, brandLogoAttachment);
+  };
+
   /**
    * @api {get} /attachments.obsoletedGetForMongoDB get attachments from mongoDB
    * @apiName get

+ 4 - 0
packages/app/src/server/routes/index.js

@@ -3,6 +3,7 @@ import express from 'express';
 
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
+import { generateCertifyBrandLogoMiddleware } from '../middlewares/certify-brand-logo';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../middlewares/login-form-validator';
@@ -30,6 +31,7 @@ module.exports = function(crowi, app) {
   const loginRequired = require('../middlewares/login-required')(crowi, true);
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
+  const certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
   const rateLimiter = require('../middlewares/rate-limiter')();
   const addActivity = generateAddActivityMiddleware(crowi);
 
@@ -106,6 +108,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromQiita);
   app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testQiitaAPI);
 
+  app.get('/attachment/brand-logo' , certifyBrandLogo, loginRequired, attachment.api.getBrandLogo);
+
   /*
    * Routes below are unavailable when maintenance mode
    */

+ 11 - 0
packages/app/src/server/service/attachment.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { AttachmentType } from '../interfaces/attachment';
+
 const fs = require('fs');
 
 const mongoose = require('mongoose');
@@ -77,6 +79,15 @@ class AttachmentService {
     return;
   }
 
+  async isBrandLogoExist() {
+    const Attachment = this.crowi.model('Attachment');
+
+    const query = { attachmentType: AttachmentType.BRAND_LOGO };
+    const count = await Attachment.countDocuments(query);
+
+    return count >= 1;
+  }
+
 }
 
 module.exports = AttachmentService;

+ 0 - 6
packages/app/src/server/service/config-loader.ts

@@ -73,12 +73,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
-  NO_CDN: {
-    ns:      'crowi',
-    key:     'app:noCdn',
-    type:    ValueType.STRING,
-    default: null,
-  },
   // PLANTUML_URI: {
   //   ns:      ,
   //   key:     ,

+ 41 - 1
packages/app/src/server/service/customize.ts

@@ -1,5 +1,6 @@
 // eslint-disable-next-line no-unused-vars
-import { DevidedPagePath } from '@growi/core';
+import { ColorScheme, DevidedPagePath, getForcedColorScheme } from '@growi/core';
+import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import uglifycss from 'uglifycss';
 
 import loggerFactory from '~/utils/logger';
@@ -7,8 +8,10 @@ import loggerFactory from '~/utils/logger';
 import S2sMessage from '../models/vo/s2s-message';
 
 import ConfigManager from './config-manager';
+import type { IPluginService } from './plugin';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
+
 const logger = loggerFactory('growi:service:CustomizeService');
 
 
@@ -25,17 +28,26 @@ class CustomizeService implements S2sMessageHandlable {
 
   xssService: any;
 
+  pluginService: IPluginService;
+
   lastLoadedAt?: Date;
 
   customCss?: string;
 
   customTitleTemplate!: string;
 
+  theme: string;
+
+  themeHref: string;
+
+  forcedColorScheme?: ColorScheme;
+
   constructor(crowi) {
     this.configManager = crowi.configManager;
     this.s2sMessagingService = crowi.s2sMessagingService;
     this.appService = crowi.appService;
     this.xssService = crowi.xssService;
+    this.pluginService = crowi.pluginService;
   }
 
   /**
@@ -60,6 +72,7 @@ class CustomizeService implements S2sMessageHandlable {
     await configManager.loadConfigs();
     this.initCustomCss();
     this.initCustomTitle();
+    this.initGrowiTheme();
   }
 
   async publishUpdatedMessage() {
@@ -137,6 +150,33 @@ class CustomizeService implements S2sMessageHandlable {
     return this.xssService.process(customTitle);
   }
 
+  async initGrowiTheme(): Promise<void> {
+    const theme = this.configManager.getConfig('crowi', 'customize:theme');
+
+    this.theme = theme;
+
+    const resultForThemePlugin = await this.pluginService.findThemePlugin(theme);
+
+    if (resultForThemePlugin != null) {
+      this.forcedColorScheme = getForcedColorScheme(resultForThemePlugin.themeMetadata.schemeType);
+      this.themeHref = resultForThemePlugin.themeHref;
+    }
+    // retrieve preset theme
+    else {
+      // import preset-themes manifest
+      const presetThemesManifest = await import('@growi/preset-themes/dist/themes/manifest.json').then(imported => imported.default);
+
+      const themeMetadata = PresetThemesMetadatas.find(p => p.name === theme);
+      this.forcedColorScheme = getForcedColorScheme(themeMetadata?.schemeType);
+
+      const manifestKey = themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
+      if (themeMetadata == null || !(themeMetadata.manifestKey in presetThemesManifest)) {
+        logger.warn(`Use default theme because the key for '${theme} does not exist in preset-themes manifest`);
+      }
+      this.themeHref = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
+    }
+
+  }
 
 }
 

+ 18 - 7
packages/app/src/server/service/plugin.ts

@@ -33,9 +33,16 @@ function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
   return JSON.parse(manifestStr);
 }
 
+
+type FindThemePluginResult = {
+  growiPlugin: GrowiPlugin,
+  themeMetadata: GrowiThemeMetadata,
+  themeHref: string,
+}
+
 export interface IPluginService {
   install(origin: GrowiPluginOrigin): Promise<string>
-  retrieveThemeHref(theme: string): Promise<string | undefined>
+  findThemePlugin(theme: string): Promise<FindThemePluginResult | null>
   retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
   downloadNotExistPluginRepositories(): Promise<void>
 }
@@ -322,8 +329,7 @@ export class PluginService implements IPluginService {
     return growiPlugins.meta.name;
   }
 
-  async retrieveThemeHref(theme: string): Promise<string | undefined> {
-
+  async findThemePlugin(theme: string): Promise<FindThemePluginResult | null> {
     const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
 
     let matchedPlugin: GrowiPlugin | undefined;
@@ -349,15 +355,20 @@ export class PluginService implements IPluginService {
       logger.error(`Could not find the theme '${theme}' from GrowiPlugin documents.`, e);
     }
 
+    if (matchedPlugin == null || matchedThemeMetadata == null) {
+      return null;
+    }
+
+    let themeHref;
     try {
-      if (matchedPlugin != null && matchedThemeMetadata != null) {
-        const manifest = await retrievePluginManifest(matchedPlugin);
-        return `${PLUGINS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
-      }
+      const manifest = retrievePluginManifest(matchedPlugin);
+      themeHref = `${PLUGINS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
     }
     catch (e) {
       logger.error(`Could not read manifest file for the theme '${theme}'`, e);
     }
+
+    return { growiPlugin: matchedPlugin, themeMetadata: matchedThemeMetadata, themeHref };
   }
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {

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