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

Merge pull request #3159 from weseek/master

release v4.2.0
Yuki Takei 5 лет назад
Родитель
Сommit
df811d284e
100 измененных файлов с 2603 добавлено и 1357 удалено
  1. 1 1
      .devcontainer/Dockerfile
  2. 7 12
      .devcontainer/docker-compose.yml
  3. 32 25
      .github/workflows/ci.yml
  4. 0 35
      .github/workflows/prerelease.yml
  5. 17 2
      .github/workflows/release-rc.yml
  6. 28 23
      .github/workflows/release.yml
  7. 2 0
      .gitignore
  8. 16 0
      CHANGES.md
  9. 1 1
      README.md
  10. 2 2
      bin/github-actions/update-readme.sh
  11. 5 5
      docker/README.md
  12. 3 3
      package.json
  13. BIN
      public/images/admin/customize/layout-classic-thumb.gif
  14. BIN
      public/images/admin/customize/layout-classic.gif
  15. BIN
      public/images/admin/customize/layout-crowi-plus-thumb.gif
  16. BIN
      public/images/admin/customize/layout-crowi-plus.gif
  17. BIN
      public/images/admin/customize/layout-kibela-thumb.gif
  18. BIN
      public/images/admin/customize/layout-kibela.gif
  19. 6 0
      public/images/icons/slack/slack-logo-dark-off.svg
  20. 6 0
      public/images/icons/slack/slack-logo-dark-on.svg
  21. 6 0
      public/images/icons/slack/slack-logo-off.svg
  22. 6 0
      public/images/icons/slack/slack-logo-on.svg
  23. 30 18
      resource/locales/en_US/admin/admin.json
  24. 26 3
      resource/locales/en_US/translation.json
  25. 27 17
      resource/locales/ja_JP/admin/admin.json
  26. 26 3
      resource/locales/ja_JP/translation.json
  27. 28 24
      resource/locales/zh_CN/admin/admin.json
  28. 34 10
      resource/locales/zh_CN/translation.json
  29. 35 44
      src/client/js/app.jsx
  30. 0 2
      src/client/js/base.jsx
  31. 1 1
      src/client/js/components/Admin/App/AppSetting.jsx
  32. 3 3
      src/client/js/components/Admin/App/AppSettingsPageContents.jsx
  33. 76 114
      src/client/js/components/Admin/App/AwsSetting.jsx
  34. 99 0
      src/client/js/components/Admin/App/FileUploadSetting.jsx
  35. 117 0
      src/client/js/components/Admin/App/GcsSettings.jsx
  36. 3 3
      src/client/js/components/Admin/App/MailSetting.jsx
  37. 2 1
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  38. 1 1
      src/client/js/components/Admin/Customize/Customize.jsx
  39. 30 55
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  40. 0 48
      src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx
  41. 0 67
      src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx
  42. 2 0
      src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx
  43. 6 13
      src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx
  44. 58 0
      src/client/js/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx
  45. 1 1
      src/client/js/components/Admin/Customize/ThemeColorBox.jsx
  46. 22 12
      src/client/js/components/Admin/ManageExternalAccount.jsx
  47. 1 1
      src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx
  48. 53 3
      src/client/js/components/Admin/Notification/NotificationSetting.jsx
  49. 0 98
      src/client/js/components/Admin/Notification/NotificationSettingContents.jsx
  50. 123 166
      src/client/js/components/Admin/Security/SecurityManagementContents.jsx
  51. 1 1
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  52. 53 32
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  53. 2 2
      src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  54. 11 6
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  55. 12 8
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  56. 3 1
      src/client/js/components/Admin/UserManagement.jsx
  57. 3 3
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  58. 9 6
      src/client/js/components/Admin/Users/UserMenu.jsx
  59. 44 47
      src/client/js/components/BookmarkButton.jsx
  60. 231 0
      src/client/js/components/CustomNavigation/CustomNav.jsx
  61. 52 0
      src/client/js/components/CustomNavigation/CustomNavAndContents.jsx
  62. 37 0
      src/client/js/components/CustomNavigation/CustomTabContent.jsx
  63. 18 0
      src/client/js/components/Drawio.jsx
  64. 33 0
      src/client/js/components/ExpandOrContractButton.jsx
  65. 12 4
      src/client/js/components/Fab.jsx
  66. 31 0
      src/client/js/components/FootstampIcon.jsx
  67. 54 0
      src/client/js/components/ForbiddenPage.jsx
  68. 10 4
      src/client/js/components/Hotkeys/Subscribers/EditPage.jsx
  69. 27 0
      src/client/js/components/Icons/AttachmentIcon.jsx
  70. 28 0
      src/client/js/components/Icons/BookmarkIcon.jsx
  71. 0 0
      src/client/js/components/Icons/GrowiLogo.jsx
  72. 21 0
      src/client/js/components/Icons/HistoryIcon.jsx
  73. 17 0
      src/client/js/components/Icons/PageListIcon.jsx
  74. 16 0
      src/client/js/components/Icons/PagePreviewIcon.jsx
  75. 22 0
      src/client/js/components/Icons/PresentationIcon.jsx
  76. 44 0
      src/client/js/components/Icons/RecentlyCreatedIcon.jsx
  77. 35 0
      src/client/js/components/Icons/ShareLinkIcon.jsx
  78. 19 0
      src/client/js/components/Icons/TimeLineIcon.jsx
  79. 39 28
      src/client/js/components/LikeButton.jsx
  80. 2 4
      src/client/js/components/Me/ApiSettings.jsx
  81. 7 9
      src/client/js/components/Me/ExternalAccountLinkedMe.jsx
  82. 4 6
      src/client/js/components/Me/PasswordSettings.jsx
  83. 40 50
      src/client/js/components/Me/PersonalSettings.jsx
  84. 2 3
      src/client/js/components/Me/ProfileImageSettings.jsx
  85. 6 8
      src/client/js/components/Me/UserSettings.jsx
  86. 7 6
      src/client/js/components/MyDraftList/Draft.jsx
  87. 7 7
      src/client/js/components/MyDraftList/MyDraftList.jsx
  88. 14 3
      src/client/js/components/Navbar/AuthorInfo.jsx
  89. 1 1
      src/client/js/components/Navbar/DrawerToggler.jsx
  90. 13 4
      src/client/js/components/Navbar/GrowiNavbar.jsx
  91. 16 3
      src/client/js/components/Navbar/GrowiNavbarBottom.jsx
  92. 56 128
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  93. 110 0
      src/client/js/components/Navbar/PageEditorModeManager.jsx
  94. 67 0
      src/client/js/components/Navbar/SubNavButtons.jsx
  95. 42 0
      src/client/js/components/NotFoundPage.jsx
  96. 2 2
      src/client/js/components/Page.jsx
  97. 148 155
      src/client/js/components/Page/CopyDropdown.jsx
  98. 74 0
      src/client/js/components/Page/DisplaySwitcher.jsx
  99. 67 0
      src/client/js/components/Page/NotFoundAlert.jsx
  100. 92 9
      src/client/js/components/Page/PageManagement.jsx

+ 1 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:14
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-14
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update

+ 7 - 12
.devcontainer/docker-compose.yml

@@ -22,19 +22,12 @@ services:
       - 3001:3001 # for browser-sync
 
     volumes:
-      - ..:/workspace/growi:cached
-      - /workspace/growi/node_modules
-      - ../../growi-docker-compose:/workspace/growi-docker-compose:cached
-      - ../../node_modules:/workspace/node_modules:cached
+      - ..:/workspace/growi:delegated
+      - node_modules:/workspace/growi/node_modules
+      - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
+      - ../../node_modules:/workspace/node_modules:delegated
 
-
-    # Overrides default command so things don't shut down after the process ends.
-    command: sleep infinity
-
-    links:
-      - mongo
-      - elasticsearch
-      - hackmd
+    tty: true
 
   mongo:
     image: mongo:4.4
@@ -86,3 +79,5 @@ services:
       - 3010:3000
     volumes:
       - /files/sqlite
+volumes:
+  node_modules:

+ 32 - 25
.github/workflows/ci.yml

@@ -23,7 +23,7 @@ jobs:
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
@@ -33,7 +33,7 @@ jobs:
       run: echo "::set-output name=dir::$(yarn cache dir)"
     - name: Cache/Restore yarn cache
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: ${{ steps.cache-yarn.outputs.dir }}
         key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
@@ -70,6 +70,12 @@ jobs:
       matrix:
         node-version: [14.x]
 
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
     steps:
     - uses: actions/checkout@v2
     - name: Use Node.js ${{ matrix.node-version }}
@@ -78,7 +84,7 @@ jobs:
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
@@ -88,7 +94,7 @@ jobs:
       run: echo "::set-output name=dir::$(yarn cache dir)"
     - name: Cache/Restore yarn cache
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: ${{ steps.cache-yarn.outputs.dir }}
         key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
@@ -103,15 +109,11 @@ jobs:
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
-    - name: Launch MongoDB
-      uses: wbari/start-mongoDB@v0.2
-      with:
-        mongoDBVersion: 3.6
     - name: yarn test
       run: |
         yarn test
       env:
-        MONGO_URI: mongodb://localhost:27017/growi_test
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -139,7 +141,7 @@ jobs:
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: node_modules
         key: ${{ runner.OS }}-node_modules_dev-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
@@ -151,21 +153,21 @@ jobs:
         echo ::set-output name=Ym::$(date '+%Y%m')
         echo ::set-output name=Y::$(date '+%Y')
     - name: Cache/Restore node_modules/.cache/hard-source
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: node_modules/.cache
-        key: ${{ runner.OS }}-hard_source_webpack_dev-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
+        key: ${{ runner.OS }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
         restore-keys: |
-          ${{ runner.os }}-hard_source_webpack_dev-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
-          ${{ runner.os }}-hard_source_webpack_dev-${{ matrix.node-version }}-${{ steps.date.outputs.Ym }}
-          ${{ runner.os }}-hard_source_webpack_dev-${{ matrix.node-version }}-${{ steps.date.outputs.Y }}
+          ${{ runner.os }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
+          ${{ runner.os }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.Ym }}
+          ${{ runner.os }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.Y }}
     - name: Get yarn cache dir
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       id: cache-yarn
       run: echo "::set-output name=dir::$(yarn cache dir)"
     - name: Cache/Restore yarn cache
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: ${{ steps.cache-yarn.outputs.dir }}
         key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -202,6 +204,12 @@ jobs:
       matrix:
         node-version: [12.x, 14.x]
 
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
     steps:
     - uses: actions/checkout@v2
     - name: Use Node.js ${{ matrix.node-version }}
@@ -216,7 +224,7 @@ jobs:
         echo ::set-output name=Ym::$(date '+%Y%m')
         echo ::set-output name=Y::$(date '+%Y')
     - name: Cache/Restore node_modules
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: node_modules
         key: ${{ runner.OS }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
@@ -228,7 +236,7 @@ jobs:
       id: cache-yarn
       run: echo "::set-output name=dir::$(yarn cache dir)"
     - name: Cache/Restore yarn cache
-      uses: actions/cache@v1
+      uses: actions/cache@v2
       with:
         path: ${{ steps.cache-yarn.outputs.dir }}
         key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -254,18 +262,17 @@ jobs:
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --production --depth=0
-    - name: Launch MongoDB
-      uses: wbari/start-mongoDB@v0.2
-      with:
-        mongoDBVersion: 3.6
+    - name: Get DB name
+      id: getdbname
+      run: |
+        echo ::set-output name=suffix::$(echo '${{ matrix.node-version }}' | sed s/\\.//)
     - name: yarn server:prod:ci
       run: |
         yarn server:prod:ci
       env:
-        MONGO_URI: mongodb://localhost:27017/growi
-
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
     - name: Upload report as artifact
-      uses: actions/upload-artifact@v1
+      uses: actions/upload-artifact@v2
       with:
         name: Report
         path: report

+ 0 - 35
.github/workflows/prerelease.yml

@@ -1,35 +0,0 @@
-name: Pre Release
-
-on:
-  push:
-    branches:
-      - master
-
-jobs:
-  build-image-for-cache:
-
-    runs-on: ubuntu-latest
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - name: Set up Docker Buildx
-      uses: crazy-max/ghaction-docker-buildx@v3
-
-    - name: Cache Docker layers
-      uses: actions/cache@v2
-      id: cache
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-default
-        restore-keys: |
-          ${{ runner.os }}-buildx-
-
-    - name: Build Docker Image
-      run: |
-        docker buildx build \
-          --cache-from "type=local,src=/tmp/.buildx-cache" \
-          --cache-to "type=local,dest=/tmp/.buildx-cache" \
-          --platform linux/amd64 \
-          --load \
-          --file ./docker/Dockerfile .

+ 17 - 2
.github/workflows/release-rc.yml

@@ -15,7 +15,7 @@ jobs:
     - uses: actions/checkout@v2
 
     - name: Set up Docker Buildx
-      uses: crazy-max/ghaction-docker-buildx@v3
+      uses: docker/setup-buildx-action@v1
 
     - name: Login to docker.io registry
       run: |
@@ -35,7 +35,7 @@ jobs:
     - name: Get SemVer
       run: |
         semver=`npm run version --silent`
-        echo ::set-env name=SEMVER::$semver
+        echo "SEMVER=$semver" >> $GITHUB_ENV
 
     - name: Docker Tags by SemVer
       uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
@@ -45,6 +45,21 @@ jobs:
         semver: ${{ env.SEMVER }}
         publish: true
 
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Docker Tags by SemVer in Github Container Registry
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+      with:
+        source: growi
+        target: ghcr.io/weseek/growi
+        semver: ${{ env.SEMVER }}
+        publish: true
+
     - name: Check whether workspace is clean
       run: |
         STATUS=`git status --porcelain`

+ 28 - 23
.github/workflows/release.yml

@@ -28,7 +28,7 @@ jobs:
         npm --no-git-tag-version version patch
         export RELEASE_VERSION=`npm run version --silent`
         sh ./bin/github-actions/update-readme.sh
-        echo ::set-env name=RELEASE_VERSION::$RELEASE_VERSION
+        echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV
         echo ::set-output name=RELEASE_VERSION::$RELEASE_VERSION
 
     - name: Checkout, Commit, Tag and Push
@@ -69,30 +69,18 @@ jobs:
     - name: Determine suffix
       run: |
         [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
-        echo ::set-env name=SUFFIX::$suffix
+        echo "SUFFIX=$suffix" >> $GITHUB_ENV
 
     - name: Set up Docker Buildx
-      uses: crazy-max/ghaction-docker-buildx@v3
+      uses: docker/setup-buildx-action@v1
 
     - name: Login to docker.io registry
       run: |
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
-    - name: Cache Docker layers
-      uses: actions/cache@v2
-      id: cache
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-${{ matrix.flavor }}
-        restore-keys: |
-          ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-
-          ${{ runner.OS }}-buildx-
-
     - name: Build Docker Image
       run: |
         docker buildx build \
-          --cache-from "type=local,src=/tmp/.buildx-cache" \
-          --cache-to "type=local,dest=/tmp/.buildx-cache" \
           --tag growi${{ env.SUFFIX }} \
           --build-arg flavor=${{ matrix.flavor }} \
           --platform linux/amd64 \
@@ -109,6 +97,31 @@ jobs:
         additional-tags: 'latest'
         publish: true
 
+    - name: Update Docker Hub Description
+      uses: peter-evans/dockerhub-description@v2
+      with:
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        repository: weseek/growi
+        readme-filepath: ./docker/README.md
+
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Docker Tags by SemVer in Github Container Registry
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+      with:
+        source: growi${{ env.SUFFIX }}
+        target: ghcr.io/weseek/growi
+        semver: ${{ needs.github-release.outputs.RELEASE_VERSION }}
+        suffix: ${{ env.SUFFIX }}
+        additional-tags: 'latest'
+        publish: true
+
     - name: Slack Notification
       uses: weseek/ghaction-release-slack-notification@master
       with:
@@ -116,14 +129,6 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         created_tag: 'v${{ needs.github-release.outputs.RELEASE_VERSION }}${{ env.SUFFIX }}'
 
-    - name: Update Docker Hub Description
-      uses: peter-evans/dockerhub-description@v2.1.0
-      env:
-        DOCKERHUB_USERNAME: wsmoogle
-        DOCKERHUB_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
-        DOCKERHUB_REPOSITORY: weseek/growi
-        README_FILEPATH: ./docker/README.md
-
     - name: Check whether workspace is clean
       run: |
         STATUS=`git status --porcelain`

+ 2 - 0
.gitignore

@@ -18,6 +18,8 @@
 # dist
 /dist/
 /report/
+/public/static/js
+/public/static/styles
 /public/uploads
 /tmp/
 

+ 16 - 0
CHANGES.md

@@ -1,5 +1,21 @@
 # CHANGES
 
+## v4.2.0-RC
+
+### BREAKING CHANGES
+
+* GROWI v4.2.x no longer support Kibela layout
+    * Kibela theme is newly added and the configuration will migrate to it automatically
+
+### Updates
+
+* Feature: File Upload Settings on admin pages
+* Improvement: Basic layout of page
+* Support: Support MongoDB 4.0, 4.2 and 4.4
+* Support: Upgrade libs
+    * migrate-mongo
+    * mongoose
+
 ## v4.1.12
 
 * Fix: Adjust line-height for pre under li

+ 1 - 1
README.md

@@ -95,7 +95,7 @@ Development
 - Node.js v12.x or v14.x
 - npm 6.x
 - yarn
-- MongoDB 3.x
+- MongoDB 4.x
 
 See [confirmed versions](https://docs.growi.org/en/dev/startup/dev-env.html#set-up-node-js-environment).
 

+ 2 - 2
bin/github-actions/update-readme.sh

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

+ 5 - 5
docker/README.md

@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.1.0`, `4.1`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
-* [`4.1.0-nocdn`, `4.1-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
-* [`4.0.11`, `4.0`(Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
-* [`4.0.11-nocdn`, `4.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
+* [`4.2.0`, `4.2`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
+* [`4.2.0-nocdn`, `4.2-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
+* [`4.1.10`, `4.1` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.10/docker/Dockerfile)
+* [`4.1.10-nocdn`, `4.1-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.10/docker/Dockerfile)
 * [`3.8.0`, `3.8`, `3` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 * [`3.8.0-nocdn`, `3.8-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 
@@ -39,7 +39,7 @@ The GROWI official docker image for production use which concludes several offic
 Requirements
 -------------
 
-* MongoDB (>= 3.6)
+* MongoDB (>= 4.4)
 
 ### Optional Dependencies
 

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.12-RC",
+  "version": "4.2.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -114,10 +114,10 @@
     "lucene-query-parser": "^1.2.0",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^7.0.1",
+    "migrate-mongo": "^8.1.4",
     "mkdirp": "^1.0.3",
     "module-alias": "^2.0.6",
-    "mongoose": "5.9.18",
+    "mongoose": "5.10.11",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",

BIN
public/images/admin/customize/layout-classic-thumb.gif


BIN
public/images/admin/customize/layout-classic.gif


BIN
public/images/admin/customize/layout-crowi-plus-thumb.gif


BIN
public/images/admin/customize/layout-crowi-plus.gif


BIN
public/images/admin/customize/layout-kibela-thumb.gif


BIN
public/images/admin/customize/layout-kibela.gif


+ 6 - 0
public/images/icons/slack/slack-logo-dark-off.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#9BA5AF;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 6 - 0
public/images/icons/slack/slack-logo-dark-on.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#DD80DE;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 6 - 0
public/images/icons/slack/slack-logo-off.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#9ba5af;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 6 - 0
public/images/icons/slack/slack-logo-on.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#af30b0;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 30 - 18
resource/locales/en_US/admin/admin.json

@@ -38,10 +38,19 @@
     "host": "Host",
     "port": "Port",
     "user": "User",
+    "initialize_mail_settings": "initialize e-mail settings",
+    "initialize_mail_modal_header": "Initialize e-mail settings",
+    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
+    "file_upload_settings":"File Upload Settings",
+    "file_upload_method":"File Upload Method",
+    "fixed_by_env_var": "This is fixed by the env var <code>FILE_UPLOAD={{fileUploadType}}</code>.",
+    "gcs_label": "GCP(GCS)",
+    "aws_label": "AWS(S3)",
+    "local_label": "Local",
+    "gridfs_label": "MongoDB(GridFS)",
+    "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "ses_settings":"SES settings",
     "test_connection": "Test connection to mail",
-    "aws_settings": "AWS settings",
-    "aws_access": "This is for AWS settings. If you complete AWS settings, file upload function, profile picture function etc will be enabled.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "bucket_name": "Bucket name",
@@ -52,7 +61,8 @@
     "load_plugins": "Load_plugins",
     "enable": "Enable",
     "disable": "Disable",
-    "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <cod>{{variable}}</code> is used."
+    "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <cod>{{variable}}</code> is used.",
+    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
   },
   "markdown_setting": {
     "lineBreak_header": "Line break setting",
@@ -90,19 +100,7 @@
     }
   },
   "customize_setting": {
-    "recommended": "Recommended",
-    "layout": "Layout",
     "theme": "Theme",
-    "layout_desc": {
-      "growi_title": "Simple and clear",
-      "growi_text1": "Full screen layout and thin margins/paddings",
-      "growi_text2": "Show and post comments at the bottom of the page",
-      "growi_text3": "Affix table-of-contents",
-      "kibela_title": "Easy viewing structure",
-      "kibela_text1": "Center aligned contents",
-      "kibela_text2": "Show and post comments at the bottom of the page",
-      "kibela_text3": "Affix Table-of-contents"
-    },
     "theme_desc": {
       "light_and_dark": "Light and dark modes",
       "unique": "Only one mode"
@@ -118,9 +116,22 @@
       "tab_switch_desc1": "Save edit tab and history tab switching in the browser and make it object for forward/back command of the browser.",
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "attach_title_header": "Add h1 section when create new page automatically",
-      "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
-      "recent_created__n_draft_num_desc": "Number of recently created pages & drafts displayed",
-      "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
+      "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
+
+      "list_num_s": "Number of list displayed on modals",
+      "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
+
+      "list_num_m": "Number of list displayed on article pages included other contents",
+      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
+
+      "list_num_l": "Number of list displayed on 'Search' pages",
+      "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
+
+      "list_num_xl": "Number of list displayed on article pages",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
+
+
+
       "stale_notification": "Display notification on stale pages",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "show_all_reply_comments": "Show all reply comments",
@@ -264,6 +275,7 @@
     "external_accounts":"External accounts",
     "create_external_account":"Create external account",
     "external_account_list": "External Account List",
+    "external_account_none":"No External Account",
     "invite": "Invite",
     "invited": "User was invited",
     "back_to_user_management": "Back to User Management",

+ 26 - 3
resource/locales/en_US/translation.json

@@ -1,5 +1,6 @@
 {
   "Help": "Help",
+  "view": "View",
   "Edit": "Edit",
   "Delete": "Delete",
   "delete_all": "Delete all",
@@ -46,7 +47,10 @@
   "List View": "List",
   "Timeline View": "Timeline",
   "History": "History",
+  "attachment_data": "Attachment Data",
+  "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "Presentation",
+  "The end": "The end",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
@@ -95,7 +99,6 @@
   "Input page name (optional)": "Input page name (optional)",
   "New Page": "New page",
   "Create under": "Create page under below:",
-  "Table of Contents": "Table of Contents",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
   "Site URL settings": "Site URL settings",
@@ -117,6 +120,7 @@
   "Specified users only": "Specified users only",
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
+  "page_list": "Page List",
   "page_list_and_search_results": "Page list / Search results",
   "scope_of_page_disclosure": "Scope of page disclosure",
   "set_point": "Set point",
@@ -136,6 +140,7 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
+  "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "personal_dropdown": {
@@ -151,6 +156,13 @@
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid."
   },
+  "not_found_page": {
+    "Create Page": "Create Page",
+    "page_not_exist_alert": "This page does not exist. Please create a new page."
+  },
+  "custom_navigation": {
+    "no_page_list": "There are no pages under <a href='{{path}}'><strong>{{ path }}</strong></a>."
+  },
   "installer": {
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
@@ -178,6 +190,7 @@
     }
   },
   "page_me_apitoken": {
+    "api_token": "API Token",
     "notice": {
       "apitoken_issued": "API token is not issued.",
       "update_token1": "You can update to generate a new API token.",
@@ -282,6 +295,9 @@
       "no_deadline":"This page has no expiration date"
     }
   },
+  "page_table_of_contents": {
+    "empty": "Table of Contents is empty"
+  },
   "page_edit": {
     "Show active line": "Show active line",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
@@ -289,6 +305,9 @@
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
     }
   },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+  },
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
@@ -428,7 +447,9 @@
     "open_sandbox": "Open Sandbox"
   },
   "hackmd": {
+    "hack_md": "HackMD",
     "not_set_up": "HackMD is not set up.",
+    "used_for_not_found": "Can not use HackMD to a page that does not exist.",
     "start_to_edit": "Start to edit with HackMD",
     "clone_page_content": "Click to clone page content and start to edit.",
     "unsaved_draft": "HackMD has unsaved draft.",
@@ -442,7 +463,9 @@
     "check_configuration": "Check your configuration following <a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
     "not_initialized": "HackmdEditor component has not initialized",
     "someone_editing": "Someone editing this page on HackMD",
-    "this_page_has_draft": "This page has a draft on HackMD"
+    "this_page_has_draft": "This page has a draft on HackMD",
+    "need_to_associate_with_growi_to_use_hackmd_refer_to_this": "To use HackMD for simultaneous multi-person editing, need to associate HackMD with GROWI.Please refer to <a href='https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html'>here</a>.",
+    "need_to_make_page": "To use HackMD, please make a new page from the <a href='#edit'>built-in editor.</a>"
   },
   "slack_notification": {
     "popover_title": "Slack Notification",
@@ -521,7 +544,7 @@
     "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
     "Local": {
       "name": "ID/Password",
-      "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}/code> .",
+      "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
       "enable_local": "Enable ID/Password"
     },
     "ldap": {

+ 27 - 17
resource/locales/ja_JP/admin/admin.json

@@ -38,10 +38,19 @@
     "host": "ホスト",
     "port": "ポート",
     "user": "ユーザー",
+    "initialize_mail_settings": "設定を初期化",
+    "initialize_mail_modal_header": "メール設定の初期化",
+    "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
+    "file_upload_settings":"ファイルアップロード設定",
+    "file_upload_method":"ファイルアップロード方法",
+    "gcs_label": "GCP(GCS)",
+    "aws_label": "AWS(S3)",
+    "local_label": "Local",
+    "gridfs_label": "MongoDB(GridFS)",
+    "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
+    "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "ses_settings":"SES設定",
     "test_connection": "接続テスト",
-    "aws_settings": "AWS設定",
-    "aws_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
     "bucket_name": "バケット名",
@@ -52,7 +61,8 @@
     "load_plugins": "プラグインを読み込む",
     "enable": "有効",
     "disable": "無効",
-    "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します"
+    "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
+    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
   },
   "markdown_setting": {
     "lineBreak_header": "Line Break設定",
@@ -90,19 +100,7 @@
     }
   },
   "customize_setting": {
-    "recommended": "おすすめ",
-    "layout": "レイアウト",
     "theme": "テーマ",
-    "layout_desc": {
-      "growi_title": "シンプル・明瞭",
-      "growi_text1": "全画面レイアウトで、余白は少なくなります。",
-      "growi_text2": "コメントはページの下部に表示されます。",
-      "growi_text3": "ページ情報は下部に表示されます。",
-      "kibela_title": "閲覧重視の構造",
-      "kibela_text1": "コンテンツが中心に表示されます。",
-      "kibela_text2": "コメントはページの下部に表示されます。",
-      "kibela_text3": "ページ情報は下部に表示されます。"
-    },
     "theme_desc" : {
       "light_and_dark": "Light/Dark モード選択あり",
       "unique": "モード選択なし"
@@ -119,8 +117,19 @@
       "tab_switch_desc2": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-      "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
-      "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
+
+      "list_num_s": "モーダルに表示されるリスト数",
+      "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
+
+      "list_num_m": "ユーザーページに表示されるリスト数",
+      "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
+
+      "list_num_l": "検索ページに表示されるリスト数",
+      "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
+
+      "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
+      "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
+
       "stale_notification": "古いページに通知を表示する",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "show_all_reply_comments": "返信コメントを全て表示する",
@@ -264,6 +273,7 @@
     "external_accounts": "外部アカウント",
     "create_external_account":"外部アカウントの作成",
     "external_account_list": "外部アカウント一覧",
+    "external_account_none":"外部アカウントはありません",
     "invite": "招待する",
     "invited": "ユーザーを招待しました",
     "back_to_user_management": "ユーザー管理に戻る",

+ 26 - 3
resource/locales/ja_JP/translation.json

@@ -1,5 +1,6 @@
 {
   "Help": "ヘルプ",
+  "view": "View",
   "Edit": "編集",
   "Delete": "削除",
   "delete_all": "全て削除",
@@ -45,9 +46,12 @@
   "Example": "例",
   "Taro Yamada": "山田 太郎",
   "List View": "リスト表示",
-  "Timeline View": "タイムライン表示",
+  "Timeline View": "タイムライン",
   "History": "更新履歴",
+  "attachment_data": "添付データ",
+  "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "プレゼンテーション",
+  "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
@@ -96,7 +100,6 @@
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",
   "Create under": "ページを以下に作成",
-  "Table of Contents": "目次",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
   "Site URL settings": "サイトURL設定",
@@ -117,6 +120,7 @@
   "Specified users": "特定ユーザーのみ",
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
+  "page_list": "ページリスト",
   "page_list_and_search_results": "ページリスト・検索結果",
   "scope_of_page_disclosure": "ページの公開範囲",
   "set_point": "設定値",
@@ -139,6 +143,7 @@
   "Color mode": "カラーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
+  "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "personal_dropdown": {
@@ -154,6 +159,13 @@
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です"
   },
+  "not_found_page": {
+    "Create Page": "ページを作成する",
+    "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
+  },
+  "custom_navigation": {
+    "no_page_list": "<a href='{{path}}'><strong>{{ path }}</strong></a>の配下にはページが存在しません。"
+  },
   "installer": {
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
@@ -181,6 +193,7 @@
     }
   },
   "page_me_apitoken": {
+    "api_token": "API Token",
     "notice": {
       "apitoken_issued": "API Token が設定されていません。",
       "update_token1": "API Token を更新すると、自動的に新しい Token が生成されます。",
@@ -284,6 +297,9 @@
       "no_deadline": "このページに有効期限は設定されていません。"
     }
   },
+  "page_table_of_contents": {
+    "empty": "目次は空です"
+  },
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
@@ -291,6 +307,9 @@
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
     }
   },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "投稿時のページを表示する"
+  },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
@@ -430,7 +449,9 @@
     "open_sandbox": "Sandbox を開く"
   },
   "hackmd":{
+    "hack_md": "HackMD",
     "not_set_up": "HackMD はセットアップされていません",
+    "used_for_not_found": "HackMD は新しいページの作成には利用できません",
     "start_to_edit": "HackMD を開始する",
     "clone_page_content": "ページを複製して編集を開始します",
     "unsaved_draft": "HackMD のドラフトが保存されていません",
@@ -444,7 +465,9 @@
     "check_configuration": "<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのマニュアル</a>から設定を確認してください",
     "not_initialized": "HackMD コンポーネントは初期化されていません",
     "someone_editing": "このページは、HackMD で編集されています。",
-    "this_page_has_draft": "このページは、HackMD のドラフトがあります。"
+    "this_page_has_draft": "このページは、HackMD のドラフトがあります。",
+    "need_to_associate_with_growi_to_use_hackmd_refer_to_this": "HackMD を利用して同時多人数編集を行うには、HackMD と GROWI を連携する必要があります。<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちら</a>を参照してください。",
+    "need_to_make_page": "HackMD を利用するためには、<a href='#edit'>ビルトインエディタ</a>で新しいページを作成してください。"
   },
   "slack_notification": {
     "popover_title": "Slack 通知",

+ 28 - 24
resource/locales/zh_CN/admin/admin.json

@@ -38,10 +38,17 @@
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
+    "initialize_mail_settings": "重置邮件设置",
+    "initialize_mail_modal_header": "重置邮件设置",
+    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
+    "file_upload_settings":"文件上传设置",
+    "file_upload_method":"文件上传方法",
+    "gcs_label": "GCP(GCS)",
+    "aws_label": "AWS(S3)",
+    "local_label": "Local",
+    "gridfs_label": "MongoDB(GridFS)",
     "ses_settings":"SES设置",
     "test_connection": "测试邮件服务器连接",
-		"aws_settings": "AWS设置",
-		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
 		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
 		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
 		"region": "Region",
@@ -53,8 +60,9 @@
 		"load_plugins": "加载插件",
 		"enable": "启用",
 		"disable": "停用",
-		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。"
-	},
+		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
+    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+  },
 	"markdown_setting": {
 		"lineBreak_header": "换行设置",
 		"lineBreak_desc": "您可以更改换行设置。",
@@ -91,23 +99,7 @@
 		}
 	},
 	"customize_setting": {
-		"recommended": "推荐",
-		"layout": "布局",
 		"theme": "主体",
-		"layout_desc": {
-			"growi_title": "简约",
-			"growi_text1": "全屏布局 窄边距/填充",
-			"growi_text2": "页面底部显示和发布评论",
-			"growi_text3": "附目录",
-			"kibela_title": "清晰",
-			"kibela_text1": "内容居中对齐",
-			"kibela_text2": "在页面底部显示和发布评论",
-			"kibela_text3": "附目录",
-			"crowi_title": "分栏",
-			"crowi_text1": "可折叠边栏",
-			"crowi_text2": "在侧边栏中显示和发布评论",
-			"crowi_text3": "可折叠目录"
-		},
 		"behavior": "行为",
 		"behavior_desc": {
 			"growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
@@ -134,9 +126,20 @@
 			"tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
 			"tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
 			"attach_title_header": "自动创建新页面时添加h1节",
-			"attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
-			"recent_created__n_draft_num_desc": "显示最近创建的页数和草稿数",
-			"recently_created_n_draft_num_desc": "用户页上显示的最近创建的页和草稿数",
+      "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
+
+      "list_num_s": "Number of list displayed on modals",
+      "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
+
+      "list_num_m": "Number of list displayed on article pages included other contents",
+      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages.",
+
+      "list_num_l": "Number of list displayed on 'Search' pages",
+      "list_num_desc_l": "Set number of list per page such as 'Search' pages.",
+
+      "list_num_xl": "Number of list displayed on article pages",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
+
 			"stale_notification": "在过期页上显示通知",
 			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
 			"show_all_reply_comments": "显示所有回复评论",
@@ -279,7 +282,8 @@
 		"external_account": "外部账户管理",
 		"external_accounts": "外部账户",
 		"create_external_account": "创建外部账户",
-		"external_account_list": "外部账户列表",
+    "external_account_list": "外部账户列表",
+    "external_account_none":"No External Account",
 		"invite": "邀请",
 		"invited": "已邀请用户",
 		"back_to_user_management": "返回用户管理",

+ 34 - 10
resource/locales/zh_CN/translation.json

@@ -1,5 +1,6 @@
 {
-	"Help": "帮助",
+  "Help": "帮助",
+  "view": "View",
 	"Edit": "编辑",
 	"Delete": "删除",
 	"delete_all": "删除所有",
@@ -47,8 +48,11 @@
 	"Taro Yamada": "John Doe",
 	"List View": "列表",
 	"Timeline View": "时间线",
-	"History": "历史",
+  "History": "历史",
+  "attachment_data": "Attachment Data",
+  "No_attachments_yet": "暂无附件",
 	"Presentation Mode": "演示文稿",
+  "The end": "结束",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",
@@ -74,6 +78,7 @@
 	"Shrink versions that have no diffs": "收缩没有差异的版本",
 	"User ID": "用户ID",
 	"Home": "首页",
+	"My Drafts": "My Drafts",
 	"User Settings": "用户设置",
 	"User Information": "用户信息",
 	"Basic Info": "基础信息",
@@ -103,7 +108,6 @@
 	"Input page name (optional)": "Input page name (optional)",
 	"New Page": "新页面",
 	"Create under": "Create page under below:",
-	"Table of Contents": "Table of Contents",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
 	"Site URL settings": "主页URL设置",
@@ -124,7 +128,8 @@
 	"Anyone with the link": "任何人",
 	"Specified users only": "仅指定用户",
 	"Only me": "只有我",
-	"Only inside the group": "仅组内",
+  "Only inside the group": "仅组内",
+  "page_list": "Page List",
 	"page_list_and_search_results": "页面列表/搜索结果",
 	"scope_of_page_disclosure": "页面公开范围",
 	"set_point": "设定值",
@@ -143,14 +148,22 @@
 	"List Drafts": "草稿",
 	"Deleted Pages": "已删除页",
 	"Sign out": "退出",
-	"Disassociate": "解除关联",
+  "Disassociate": "解除关联",
+  "No bookmarks yet": "暂无书签",
 	"Recent Created": "最新创建",
 	"Recent Changes": "最新修改",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。"
-	},
+  },
+  "not_found_page": {
+    "Create Page": "创建页面",
+    "page_not_exist_alert": "该页面不存在,请创建一个新页面"
+  },
+  "custom_navigation": {
+    "no_page_list": "There are no pages under <a href='{{path}}'><strong>{{ path }}</strong></a>."
+  },
 	"installer": {
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",
@@ -179,6 +192,7 @@
 		}
 	},
 	"page_me_apitoken": {
+    "api_token": "API Token",
 		"notice": {
 			"apitoken_issued": "API token 未发布。",
 			"update_token1": "您可以更新以生成新的API令牌。",
@@ -268,7 +282,13 @@
 		"notice": {
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
-	},
+  },
+  "page_table_of_contents": {
+    "empty": "目录为空"
+  },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+  },
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "新建页面已存在",
@@ -404,7 +424,9 @@
 		"open_sandbox": "开放式沙箱"
 	},
 	"hackmd": {
-		"not_set_up": "HackMD is not set up.",
+    "hack_md": "HackMD",
+    "not_set_up": "HackMD is not set up.",
+    "used_for_not_found": "Can not use HackMD to a page that does not exist.",
 		"start_to_edit": "Start to edit with HackMD",
 		"clone_page_content": "Click to clone page content and start to edit.",
 		"unsaved_draft": "HackMD has unsaved draft.",
@@ -418,7 +440,9 @@
 		"check_configuration": "Check your configuration following <a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
 		"not_initialized": "HackmdEditor component has not initialized",
 		"someone_editing": "Someone editing this page on HackMD",
-		"this_page_has_draft": "This page has a draft on HackMD"
+    "this_page_has_draft": "This page has a draft on HackMD",
+    "need_to_associate_with_growi_to_use_hackmd_refer_to_this": "若要使用HackMD的多人同时编辑功能,请先关联HackMD和GROWI。详情请参考<a href='https://docs.growi.org/cn/admin-guide/admin-cookbook/integrate-with-hackmd.html'>这里</a>。",
+    "need_to_make_page": "To use HackMD, please make a new page from the <a href='#edit'>built-in editor.</a>"
   },
   "slack_notification": {
     "popover_title": "Slack Notification",
@@ -508,7 +532,7 @@
 		"missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
 		"Local": {
 			"name": "ID/Password",
-			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}/code> .",
+			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
 			"enable_local": "Enable ID/Password"
 		},
 		"ldap": {

+ 35 - 44
src/client/js/app.jsx

@@ -8,37 +8,39 @@ import loggerFactory from '@alias/logger';
 import ErrorBoundary from './components/ErrorBoudary';
 import SearchPage from './components/SearchPage';
 import TagsList from './components/TagsList';
-import PageEditor from './components/PageEditor';
-import PagePathNavForEditor from './components/PageEditor/PagePathNavForEditor';
-import EditorNavbarBottom from './components/PageEditor/EditorNavbarBottom';
+import DisplaySwitcher from './components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
-import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
-import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
+import PageContentFooter from './components/PageContentFooter';
 import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
-import PageShareManagement from './components/Page/PageShareManagement';
+import ShareLinkAlert from './components/Page/ShareLinkAlert';
+import TrashPageList from './components/TrashPageList';
 import TrashPageAlert from './components/Page/TrashPageAlert';
-import PageAttachment from './components/PageAttachment';
+import NotFoundPage from './components/NotFoundPage';
+import NotFoundAlert from './components/Page/NotFoundAlert';
+import ForbiddenPage from './components/ForbiddenPage';
 import PageStatusAlert from './components/PageStatusAlert';
 import RecentCreated from './components/RecentCreated/RecentCreated';
+import RecentlyCreatedIcon from './components/Icons/RecentlyCreatedIcon';
 import MyDraftList from './components/MyDraftList/MyDraftList';
-import SeenUserList from './components/User/SeenUserList';
+import BookmarkIcon from './components/Icons/BookmarkIcon';
+import BookmarkList from './components/PageList/BookmarkList';
 import LikerList from './components/User/LikerList';
-import TableOfContents from './components/TableOfContents';
 import Fab from './components/Fab';
-
 import PersonalSettings from './components/Me/PersonalSettings';
+import UserContentsLinks from './components/UserContentsLinks';
+import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
+import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSwitcher';
+
 import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
 import PageHistoryContainer from './services/PageHistoryContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
-import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
-import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSwitcher';
 import PersonalContainer from './services/PersonalContainer';
 
 import { appContainer, componentMappings } from './base';
@@ -79,11 +81,26 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
+  'trash-page-list': <TrashPageList />,
+
+  'not-found-page': <NotFoundPage />,
+  'not-found-alert': <NotFoundAlert
+    onPageCreateClicked={navigationContainer.setEditorMode}
+    isGuestUserMode={appContainer.isGuestUser}
+    isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
+  />,
+
+  'forbidden-page': <ForbiddenPage />,
+
   'page-timeline': <PageTimeline />,
 
   'personal-setting': <PersonalSettings crowi={personalContainer} />,
 
+  'my-drafts': <MyDraftList />,
+
   'grw-fab-container': <Fab />,
+
+  'share-link-alert': <ShareLinkAlert />,
 });
 
 // additional definitions if data exists
@@ -91,20 +108,19 @@ if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-attachment': <PageAttachment />,
     'page-management': <PageManagement />,
-    'page-share-management': <PageShareManagement />,
-
-    'revision-toc': <TableOfContents />,
-    'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
+    'page-content-footer': <PageContentFooter />,
 
-    'user-draft-list': <MyDraftList />,
+    'recent-created-icon': <RecentlyCreatedIcon />,
+    'user-bookmark-icon': <BookmarkIcon />,
+    'grw-user-contents-links': <UserContentsLinks />,
   });
 }
 if (pageContainer.state.creator != null) {
   Object.assign(componentMappings, {
     'user-created-list': <RecentCreated userId={pageContainer.state.creator._id} />,
+    'user-bookmark-list': <BookmarkList userId={pageContainer.state.creator._id} />,
   });
 }
 if (pageContainer.state.path != null) {
@@ -113,21 +129,9 @@ if (pageContainer.state.path != null) {
     'page': <Page />,
     'grw-subnav-container': <GrowiSubNavigation />,
     'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'display-switcher': <DisplaySwitcher />,
   });
 }
-// additional definitions if user is logged in
-if (appContainer.currentUser != null) {
-  Object.assign(componentMappings, {
-    'page-editor': <PageEditor />,
-    'page-editor-path-nav': <PagePathNavForEditor />,
-    'page-editor-navbar-bottom-container': <EditorNavbarBottom />,
-  });
-  if (pageContainer.state.pageId != null) {
-    Object.assign(componentMappings, {
-      'page-editor-with-hackmd': <PageEditorByHackmd />,
-    });
-  }
-}
 
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
@@ -145,18 +149,5 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
-// うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
-$('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <ErrorBoundary>
-        <Provider inject={injectableContainers}>
-          <PageHistory />
-        </Provider>
-      </ErrorBoundary>
-    </I18nextProvider>, document.getElementById('revision-history'),
-  );
-});
-
 // initialize scrollpos-styler
 ScrollPosStyler.init();

+ 0 - 2
src/client/js/base.jsx

@@ -6,7 +6,6 @@ import Xss from '@commons/service/xss';
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
-import ShareLinkAlert from './components/Page/ShareLinkAlert';
 import HotkeysManager from './components/Hotkeys/HotkeysManager';
 
 import AppContainer from './services/AppContainer';
@@ -47,7 +46,6 @@ const componentMappings = {
 
   'grw-hotkeys-manager': <HotkeysManager />,
 
-  'share-link-alert': <ShareLinkAlert />,
 };
 
 export { appContainer, componentMappings };

+ 1 - 1
src/client/js/components/Admin/App/AppSetting.jsx

@@ -81,7 +81,7 @@ class AppSetting extends React.Component {
           >
             {t('admin:app_setting.default_language')}
           </label>
-          <div className="col-md-6">
+          <div className="col-md-6 py-2">
             {
               localeMetadatas.map(meta => (
                 <div key={meta.id} className="custom-control custom-radio custom-control-inline">

+ 3 - 3
src/client/js/components/Admin/App/AppSettingsPageContents.jsx

@@ -5,8 +5,8 @@ import PropTypes from 'prop-types';
 import AppSetting from './AppSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import MailSetting from './MailSetting';
-import AwsSetting from './AwsSetting';
 import PluginSetting from './PluginSetting';
+import FileUploadSetting from './FileUploadSetting';
 
 class AppSettingsPageContents extends React.Component {
 
@@ -38,8 +38,8 @@ class AppSettingsPageContents extends React.Component {
 
         <div className="row mt-5">
           <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.aws_settings')}</h2>
-            <AwsSetting />
+            <h2 className="admin-setting-header">{t('admin:app_setting.file_upload_settings')}</h2>
+            <FileUploadSetting />
           </div>
         </div>
 

+ 76 - 114
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -1,142 +1,104 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-const logger = loggerFactory('growi:appSettings');
-
-class AwsSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
-
-    try {
-      await adminAppContainer.updateAwsSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.aws_settings') }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <p className="card well">
-          {t('admin:app_setting.aws_access')}
-          <br />
-          <span className="text-danger">
-            <i className="ti-unlink"></i>
-            {t('admin:app_setting.change_setting')}
-          </span>
-        </p>
-
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
-            {t('admin:app_setting.region')}
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              placeholder={`${t('eg')} ap-northeast-1`}
-              defaultValue={adminAppContainer.state.region || ''}
-              onChange={(e) => {
-                adminAppContainer.changeRegion(e.target.value);
+function AwsSetting(props) {
+  const { t, adminAppContainer } = props;
+
+  return (
+    <React.Fragment>
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.region')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            placeholder={`${t('eg')} ap-northeast-1`}
+            defaultValue={adminAppContainer.state.s3Region || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3Region(e.target.value);
               }}
-            />
-          </div>
+          />
         </div>
-
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
-            {t('admin:app_setting.custom_endpoint')}
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              placeholder={`${t('eg')} http://localhost:9000`}
-              defaultValue={adminAppContainer.state.customEndpoint || ''}
-              onChange={(e) => {
-                adminAppContainer.changeCustomEndpoint(e.target.value);
+      </div>
+
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.custom_endpoint')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            placeholder={`${t('eg')} http://localhost:9000`}
+            defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3CustomEndpoint(e.target.value);
               }}
-            />
-            <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
-          </div>
+          />
+          <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
         </div>
-
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
-            {t('admin:app_setting.bucket_name')}
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              placeholder={`${t('eg')} crowi`}
-              defaultValue={adminAppContainer.state.bucket || ''}
-              onChange={(e) => {
-                adminAppContainer.changeBucket(e.target.value);
+      </div>
+
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.bucket_name')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            placeholder={`${t('eg')} crowi`}
+            defaultValue={adminAppContainer.state.s3Bucket || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3Bucket(e.target.value);
               }}
-            />
-          </div>
+          />
         </div>
+      </div>
 
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
             Access key ID
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              defaultValue={adminAppContainer.state.accessKeyId || ''}
-              onChange={(e) => {
-                adminAppContainer.changeAccessKeyId(e.target.value);
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3AccessKeyId(e.target.value);
               }}
-            />
-          </div>
+          />
         </div>
+      </div>
 
-        <div className="row form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">
+      <div className="row form-group">
+        <label className="text-left text-md-right col-md-3 col-form-label">
             Secret access key
-          </label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              defaultValue={adminAppContainer.state.secretAccessKey || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSecretAccessKey(e.target.value);
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
+            onChange={(e) => {
+                adminAppContainer.changeS3SecretAccessKey(e.target.value);
               }}
-            />
-          </div>
+          />
         </div>
-
-        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-      </React.Fragment>
-    );
-  }
-
+      </div>
+    </React.Fragment>
+  );
 }
 
+
 /**
  * Wrapper component for using unstated
  */

+ 99 - 0
src/client/js/components/Admin/App/FileUploadSetting.jsx

@@ -0,0 +1,99 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+import AwsSetting from './AwsSetting';
+import GcsSettings from './GcsSettings';
+
+function FileUploadSetting(props) {
+
+  const { t, adminAppContainer } = props;
+  const { fileUploadType } = adminAppContainer.state;
+  const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'];
+
+  async function submitHandler() {
+    const { t } = props;
+
+    try {
+      await adminAppContainer.updateFileUploadSettingHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  return (
+    <React.Fragment>
+      <p className="card well my-3">
+        {t('admin:app_setting.file_upload')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="ti-unlink"></i>
+          {t('admin:app_setting.change_setting')}
+        </span>
+      </p>
+
+      <div className="row form-group mb-3">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_upload_method')}
+        </label>
+
+        <div className="col-md-6 py-2">
+          {fileUploadTypes.map((type) => {
+              return (
+                <div key={type} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    name="file-upload-type"
+                    id={`file-upload-type-radio-${type}`}
+                    checked={adminAppContainer.state.fileUploadType === type}
+                    disabled={adminAppContainer.state.isFixedFileUploadByEnvVar}
+                    onChange={() => { adminAppContainer.changeFileUploadType(type) }}
+                  />
+                  <label className="custom-control-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
+                </div>
+              );
+            })}
+        </div>
+        {adminAppContainer.state.isFixedFileUploadByEnvVar && (
+          <p className="alert alert-warning mt-2 text-left offset-3 col-6">
+            <i className="icon-exclamation icon-fw">
+            </i><b>FIXED</b><br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: adminAppContainer.state.envFileUploadType }) }} />
+          </p>
+        )}
+      </div>
+
+      {fileUploadType === 'aws' && <AwsSetting />}
+      {fileUploadType === 'gcs' && <GcsSettings />}
+
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+
+    </React.Fragment>
+  );
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const FileUploadSettingWrapper = withUnstatedContainers(FileUploadSetting, [AppContainer, AdminAppContainer]);
+
+FileUploadSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(FileUploadSettingWrapper);

+ 117 - 0
src/client/js/components/Admin/App/GcsSettings.jsx

@@ -0,0 +1,117 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+
+function GcsSetting(props) {
+  const { t, adminAppContainer } = props;
+  const { gcsUseOnlyEnvVars } = adminAppContainer.state;
+
+  return (
+    <>
+      {gcsUseOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.note_for_the_only_env_option', { env: 'GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+        />
+      )}
+      <table className={`table settings-table ${gcsUseOnlyEnvVars && 'use-only-env-vars'}`}>
+        <colgroup>
+          <col className="item-name" />
+          <col className="from-db" />
+          <col className="from-env-vars" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Database</th>
+            <th>Environment variables</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <th>Api Key Json Path</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="gcsApiKeyJsonPath"
+                readOnly={gcsUseOnlyEnvVars}
+                defaultValue={adminAppContainer.state.gcsApiKeyJsonPath}
+                onChange={e => adminAppContainer.changeGcsApiKeyJsonPath(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={adminAppContainer.state.envGcsApiKeyJsonPath || ''} readOnly tabIndex="-1" />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_API_KEY_JSON_PATH' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.bucket_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="gcsBucket"
+                readOnly={gcsUseOnlyEnvVars}
+                defaultValue={adminAppContainer.state.gcsBucket}
+                onChange={e => adminAppContainer.changeGcsBucket(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={adminAppContainer.state.envGcsBucket || ''} readOnly tabIndex="-1" />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_BUCKET' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>Name Space</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="gcsUploadNamespace"
+                readOnly={gcsUseOnlyEnvVars}
+                defaultValue={adminAppContainer.state.gcsUploadNamespace}
+                onChange={e => adminAppContainer.changeGcsUploadNamespace(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={adminAppContainer.state.envGcsUploadNamespace || ''} readOnly tabIndex="-1" />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_UPLOAD_NAMESPACE' }) }} />
+              </p>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </>
+  );
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const GcsSettingWrapper = withUnstatedContainers(GcsSetting, [AppContainer, AdminAppContainer]);
+
+GcsSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(GcsSettingWrapper);

+ 3 - 3
src/client/js/components/Admin/App/MailSetting.jsx

@@ -62,7 +62,7 @@ function MailSetting(props) {
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.transmission_method')}
         </label>
-        <div className="col-md-6">
+        <div className="col-md-6 py-2">
           {transmissionMethods.map((method) => {
               return (
                 <div key={method} className="custom-control custom-radio custom-control-inline">
@@ -70,13 +70,13 @@ function MailSetting(props) {
                     type="radio"
                     className="custom-control-input"
                     name="transmission-method"
-                    id={`transmission-nethod-radio-${method}`}
+                    id={`transmission-method-radio-${method}`}
                     checked={adminAppContainer.state.transmissionMethod === method}
                     onChange={(e) => {
                     adminAppContainer.changeTransmissionMethod(method);
                   }}
                   />
-                  <label className="custom-control-label" htmlFor={`transmission-nethod-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
+                  <label className="custom-control-label" htmlFor={`transmission-method-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
                 </div>
               );
             })}

+ 2 - 1
src/client/js/components/Admin/Common/AdminNavigation.jsx

@@ -39,7 +39,7 @@ const AdminNavigation = (props) => {
     return (
       <a
         href={isRoot ? '/admin' : urljoin('/admin', menu)}
-        className={`${pageTransitionClassName} ${isActive && 'active'}`}
+        className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
       >
         <MenuLabel menu={menu} />
       </a>
@@ -81,6 +81,7 @@ const AdminNavigation = (props) => {
           className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
           type="button"
           id="dropdown-admin-navigation"
+          data-display="static"
           data-toggle="dropdown"
           aria-haspopup="true"
           aria-expanded="false"

+ 1 - 1
src/client/js/components/Admin/Customize/Customize.jsx

@@ -10,7 +10,7 @@ import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
 import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeLayoutSetting from './CustomizeThemeSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
 import CustomizeCssSetting from './CustomizeCssSetting';

+ 30 - 55
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -1,10 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import {
-  Card, CardBody,
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
-} from 'reactstrap';
+import { Card, CardBody } from 'reactstrap';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
@@ -14,6 +11,7 @@ import AppContainer from '../../../services/AppContainer';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import CustomizeFunctionOption from './CustomizeFunctionOption';
+import PagingSizeUncontrolledDropdown from './PagingSizeUncontrolledDropdown';
 
 class CustomizeFunctionSetting extends React.Component {
 
@@ -21,17 +19,10 @@ class CustomizeFunctionSetting extends React.Component {
     super(props);
 
     this.state = {
-      isDropdownOpen: false,
     };
-
-    this.onToggleDropdown = this.onToggleDropdown.bind(this);
     this.onClickSubmit = this.onClickSubmit.bind(this);
   }
 
-  onToggleDropdown() {
-    this.setState({ isDropdownOpen: !this.state.isDropdownOpen });
-  }
-
   async onClickSubmit() {
     const { t, adminCustomizeContainer } = this.props;
 
@@ -59,23 +50,6 @@ class CustomizeFunctionSetting extends React.Component {
             </Card>
 
 
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isEnabledTimeline"
-                  label={t('admin:customize_setting.function_options.timeline')}
-                  isChecked={adminCustomizeContainer.state.isEnabledTimeline}
-                  onChecked={() => { adminCustomizeContainer.switchEnableTimeline() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.timeline_desc1')}<br />
-                    {t('admin:customize_setting.function_options.timeline_desc2')}<br />
-                    {t('admin:customize_setting.function_options.timeline_desc3')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-
             <div className="form-group row">
               <div className="offset-md-3 col-md-6 text-left">
                 <CustomizeFunctionOption
@@ -91,7 +65,6 @@ class CustomizeFunctionSetting extends React.Component {
                 </CustomizeFunctionOption>
               </div>
             </div>
-
             <div className="form-group row">
               <div className="offset-md-3 col-md-6 text-left">
                 <CustomizeFunctionOption
@@ -107,32 +80,34 @@ class CustomizeFunctionSetting extends React.Component {
               </div>
             </div>
 
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <div className="my-0 w-100">
-                  <label>{t('admin:customize_setting.function_options.recent_created__n_draft_num_desc')}</label>
-                </div>
-                <Dropdown isOpen={this.state.isDropdownOpen} toggle={this.onToggleDropdown}>
-                  <DropdownToggle className="text-right col-6" caret>
-                    <span className="float-left">{adminCustomizeContainer.state.currentRecentCreatedLimit}</span>
-                  </DropdownToggle>
-                  <DropdownMenu className="dropdown-menu" role="menu">
-                    <DropdownItem key={10} role="presentation" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(10) }}>
-                      <a role="menuitem">10</a>
-                    </DropdownItem>
-                    <DropdownItem key={30} role="presentation" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(30) }}>
-                      <a role="menuitem">30</a>
-                    </DropdownItem>
-                    <DropdownItem key={50} role="presentation" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(50) }}>
-                      <a role="menuitem">50</a>
-                    </DropdownItem>
-                  </DropdownMenu>
-                </Dropdown>
-                <p className="form-text text-muted">
-                  {t('admin:customize_setting.function_options.recently_created_n_draft_num_desc')}
-                </p>
-              </div>
-            </div>
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_s')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_s')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationS || 20}
+              dropdownItemSize={[10, 20, 50, 100]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationS}
+            />
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_m')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_m')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationM || 10}
+              dropdownItemSize={[5, 10, 20, 50, 100]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationM}
+            />
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_l')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_l')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationL || 50}
+              dropdownItemSize={[20, 50, 100, 200]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationL}
+            />
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_xl')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_xl')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationXL || 20}
+              dropdownItemSize={[5, 10, 20, 50, 100]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationXL}
+            />
 
             <div className="form-group row">
               <div className="offset-md-3 col-md-6 text-left">

+ 0 - 48
src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx

@@ -1,48 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-class CustomizeLayoutOption extends React.Component {
-
-  render() {
-    const { layoutType } = this.props;
-
-    return (
-      <React.Fragment>
-        <h4>
-          <div className="custom-control custom-radio">
-            <input
-              type="radio"
-              className="custom-control-input"
-              id={`radio-layout-${layoutType}`}
-              checked={this.props.isSelected}
-              onChange={this.props.onSelected}
-            />
-            <label className="custom-control-label" htmlFor={`radio-layout-${layoutType}`}>
-              {/* eslint-disable-next-line react/no-danger */}
-              <span dangerouslySetInnerHTML={{ __html: this.props.labelHtml }} />
-            </label>
-          </div>
-        </h4>
-        <a href={`/images/admin/customize/layout-${layoutType}.gif`} className="ss-container">
-          <img src={`/images/admin/customize/layout-${layoutType}-thumb.gif`} width="240px" />
-        </a>
-        {/* render layout description */}
-        {this.props.children}
-      </React.Fragment>
-    );
-  }
-
-}
-
-CustomizeLayoutOption.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  layoutType: PropTypes.string.isRequired,
-  labelHtml: PropTypes.string.isRequired,
-  isSelected: PropTypes.bool.isRequired,
-  onSelected: PropTypes.func.isRequired,
-  children: PropTypes.array.isRequired,
-};
-
-export default withTranslation()(CustomizeLayoutOption);

+ 0 - 67
src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx

@@ -1,67 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
-import AppContainer from '../../../services/AppContainer';
-
-import CustomizeLayoutOption from './CustomizeLayoutOption';
-
-class CustomizeLayoutOptions extends React.Component {
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    return (
-      <div className="row row-cols-1 row-cols-md-2">
-        <div className="col text-center">
-          <CustomizeLayoutOption
-            layoutType="crowi-plus"
-            isSelected={adminCustomizeContainer.state.currentLayout === 'growi'}
-            onSelected={() => adminCustomizeContainer.switchLayoutType('growi')}
-            labelHtml={`GROWI enhanced layout <small class="text-success">${t('admin:customize_setting.recommended')}</small>`}
-          >
-            <h4>{t('admin:customize_setting.layout_desc.growi_title')}</h4>
-            <div className="text-justify d-inline-block">
-              <ul>
-                <li>{t('admin:customize_setting.layout_desc.growi_text1')}</li>
-                <li>{t('admin:customize_setting.layout_desc.growi_text2')}</li>
-                <li>{t('admin:customize_setting.layout_desc.growi_text3')}</li>
-              </ul>
-            </div>
-          </CustomizeLayoutOption>
-        </div>
-
-        <div className="col text-center">
-          <CustomizeLayoutOption
-            layoutType="kibela"
-            isSelected={adminCustomizeContainer.state.currentLayout === 'kibela'}
-            onSelected={() => adminCustomizeContainer.switchLayoutType('kibela')}
-            labelHtml="Kibela like layout"
-          >
-            <h4>{t('admin:customize_setting.layout_desc.kibela_title')}</h4>
-            <div className="text-justify d-inline-block">
-              <ul>
-                <li>{t('admin:customize_setting.layout_desc.kibela_text1')}</li>
-                <li>{t('admin:customize_setting.layout_desc.kibela_text2')}</li>
-                <li>{t('admin:customize_setting.layout_desc.kibela_text3')}</li>
-              </ul>
-            </div>
-          </CustomizeLayoutOption>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-const CustomizeLayoutOptionsWrapper = withUnstatedContainers(CustomizeLayoutOptions, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeLayoutOptions.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeLayoutOptionsWrapper);

+ 2 - 0
src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -38,6 +38,8 @@ class CustomizeThemeOptions extends React.Component {
       name: 'future',     bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
     }, {
       name: 'halloween',  bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
+    }, {
+      name: 'kibela',  bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
     }];
     /* eslint-enable no-multi-spaces */
 

+ 6 - 13
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx → src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx

@@ -7,12 +7,11 @@ import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
 
-import CustomizeLayoutOptions from './CustomizeLayoutOptions';
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-class CustomizeLayoutSetting extends React.Component {
+class CustomizeThemeSetting extends React.Component {
 
   constructor(props) {
     super(props);
@@ -24,8 +23,8 @@ class CustomizeLayoutSetting extends React.Component {
     const { t, adminCustomizeContainer } = this.props;
 
     try {
-      await adminCustomizeContainer.updateCustomizeLayoutAndTheme();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.layout') }));
+      await adminCustomizeContainer.updateCustomizeTheme();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
     }
     catch (err) {
       toastError(err);
@@ -48,12 +47,6 @@ class CustomizeLayoutSetting extends React.Component {
 
     return (
       <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.layout')}</h2>
-            <CustomizeLayoutOptions />
-          </div>
-        </div>
         <div className="row">
           <div className="col-12">
             <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
@@ -68,12 +61,12 @@ class CustomizeLayoutSetting extends React.Component {
 
 }
 
-const CustomizeLayoutSettingWrapper = withUnstatedContainers(CustomizeLayoutSetting, [AppContainer, AdminCustomizeContainer]);
+const CustomizeThemeSettingWrapper = withUnstatedContainers(CustomizeThemeSetting, [AppContainer, AdminCustomizeContainer]);
 
-CustomizeLayoutSetting.propTypes = {
+CustomizeThemeSetting.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 
-export default withTranslation()(CustomizeLayoutSettingWrapper);
+export default withTranslation()(CustomizeThemeSettingWrapper);

+ 58 - 0
src/client/js/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx

@@ -0,0 +1,58 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import {
+  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+
+const PagingSizeUncontrolledDropdown = (props) => {
+
+  function dropdownItemOnClickHandler(num) {
+    if (props.onChangeDropdownItem === null) {
+      return;
+    }
+    props.onChangeDropdownItem(num);
+  }
+
+  return (
+    <React.Fragment>
+      <div className="form-group row">
+        <div className="offset-md-3 col-md-6 text-left">
+          <div className="my-0 w-100">
+            <label>{props.label}</label>
+          </div>
+          <UncontrolledDropdown>
+            <DropdownToggle className="text-right col-6" caret>
+              <span className="float-left">{props.toggleLabel}</span>
+            </DropdownToggle>
+            <DropdownMenu className="dropdown-menu" role="menu">
+              {props.dropdownItemSize.map((num) => {
+                return (
+                  <DropdownItem key={num} role="presentation" onClick={() => dropdownItemOnClickHandler(num)}>
+                    <a role="menuitem">{num}</a>
+                  </DropdownItem>
+                );
+              })}
+            </DropdownMenu>
+          </UncontrolledDropdown>
+          <p className="form-text text-muted">
+            {props.desc}
+          </p>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+
+PagingSizeUncontrolledDropdown.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  label: PropTypes.string,
+  toggleLabel: PropTypes.number,
+  dropdownItemSize: PropTypes.array,
+  desc: PropTypes.string,
+  onChangeDropdownItem: PropTypes.func,
+};
+
+export default withTranslation()(PagingSizeUncontrolledDropdown);

+ 1 - 1
src/client/js/components/Admin/Customize/ThemeColorBox.jsx

@@ -15,7 +15,7 @@ class ThemeColorBox extends React.PureComponent {
         className={`theme-option-container d-flex flex-column align-items-center ${isSelected && 'active'}`}
         onClick={onSelected}
       >
-        <a id={name} type="button" className={`m-0 ${name} theme-button`}>
+        <a id={name} role="button" className={`m-0 ${name} theme-button`}>
           <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
             <g>
               <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={bg}></path>

+ 22 - 12
src/client/js/components/Admin/ManageExternalAccount.jsx

@@ -34,16 +34,18 @@ class ManageExternalAccount extends React.Component {
 
   render() {
     const { t, adminExternalAccountsContainer } = this.props;
+    const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
+
 
     const pager = (
-      <div className="pull-right">
-        <PaginationWrapper
-          activePage={adminExternalAccountsContainer.state.activePage}
-          changePage={this.handleExternalAccountPage}
-          totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
-          pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
-        />
-      </div>
+      <PaginationWrapper
+        activePage={activePage}
+        changePage={this.handleExternalAccountPage}
+        totalItemsCount={totalAccounts}
+        pagingLimit={pagingLimit}
+        align="center"
+        size="sm"
+      />
     );
     return (
       <Fragment>
@@ -55,10 +57,18 @@ class ManageExternalAccount extends React.Component {
         </p>
 
         <h2>{t('admin:user_management.external_account_list')}</h2>
-
-        {pager}
-        <ExternalAccountTable />
-        {pager}
+        {(totalAccounts !== 0) ? (
+          <>
+            {pager}
+            <ExternalAccountTable />
+            {pager}
+          </>
+         )
+         : (
+           <>
+             {t('admin:user_management.external_account_none')}
+           </>
+)}
 
       </Fragment>
     );

+ 1 - 1
src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx

@@ -79,7 +79,7 @@ class LineBreakForm extends React.Component {
             onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments }) }}
           />
           <label className="custom-control-label" htmlFor="isEnabledLinebreaksInComments">
-            {t('admin:markdown_setting.lineBreak_options.enable_lineBreak') }
+            {t('admin:markdown_setting.lineBreak_options.enable_lineBreak_for_comment') }
           </label>
         </div>
         <p className="form-text text-muted" dangerouslySetInnerHTML={helpLineBreakInComment} />

+ 53 - 3
src/client/js/components/Admin/Notification/NotificationSetting.jsx

@@ -1,8 +1,9 @@
-import React from 'react';
+import React, { useMemo, useState } from 'react';
 import PropTypes from 'prop-types';
 
 import loggerFactory from '@alias/logger';
 
+import { TabContent, TabPane } from 'reactstrap';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
@@ -10,13 +11,26 @@ import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
 
-import NotificationSettingContents from './NotificationSettingContents';
+import { CustomNavTab } from '../../CustomNavigation/CustomNav';
+
+import SlackAppConfiguration from './SlackAppConfiguration';
+import UserTriggerNotification from './UserTriggerNotification';
+import GlobalNotification from './GlobalNotification';
 
 const logger = loggerFactory('growi:NotificationSetting');
 
 let retrieveErrors = null;
 function NotificationSetting(props) {
   const { adminNotificationContainer } = props;
+
+  const [activeTab, setActiveTab] = useState('slack_configuration');
+  const [activeComponents, setActiveComponents] = useState(new Set(['slack_configuration']));
+
+  const switchActiveTab = (selectedTab) => {
+    setActiveTab(selectedTab);
+    setActiveComponents(activeComponents.add(selectedTab));
+  };
+
   if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrl) {
     throw (async() => {
       try {
@@ -36,7 +50,43 @@ function NotificationSetting(props) {
     throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
-  return <NotificationSettingContents />;
+  const navTabMapping = useMemo(() => {
+    return {
+      slack_configuration: {
+        Icon: () => <i className="icon-settings" />,
+        i18n: 'Slack configuration',
+        index: 0,
+      },
+      user_trigger_notification: {
+        Icon: () => <i className="icon-settings" />,
+        i18n: 'User trigger notification',
+        index: 1,
+      },
+      global_notification: {
+        Icon: () => <i className="icon-settings" />,
+        i18n: 'Global notification',
+        index: 2,
+      },
+    };
+  }, []);
+
+  return (
+    <>
+      <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
+
+      <TabContent activeTab={activeTab} className="p-5">
+        <TabPane tabId="slack_configuration">
+          {activeComponents.has('slack_configuration') && <SlackAppConfiguration />}
+        </TabPane>
+        <TabPane tabId="user_trigger_notification">
+          {activeComponents.has('user_trigger_notification') && <UserTriggerNotification />}
+        </TabPane>
+        <TabPane tabId="global_notification">
+          {activeComponents.has('global_notification') && <GlobalNotification />}
+        </TabPane>
+      </TabContent>
+    </>
+  );
 }
 
 const NotificationSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(NotificationSetting), [AdminNotificationContainer]);

+ 0 - 98
src/client/js/components/Admin/Notification/NotificationSettingContents.jsx

@@ -1,98 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  TabContent, TabPane, Nav, NavItem, NavLink,
-} from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import AppContainer from '../../../services/AppContainer';
-import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
-
-import SlackAppConfiguration from './SlackAppConfiguration';
-import UserTriggerNotification from './UserTriggerNotification';
-import GlobalNotification from './GlobalNotification';
-
-
-class NotificationSettingContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      activeTab: 'slack-configuration',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['slack-configuration']),
-    };
-
-    this.toggleActiveTab = this.toggleActiveTab.bind(this);
-  }
-
-  toggleActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-  render() {
-    const { activeTab, activeComponents } = this.state;
-
-    return (
-      <React.Fragment>
-        <Nav tabs>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'slack-configuration' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('slack-configuration') }}
-              href="#slack-configuration"
-            >
-              <i className="icon-settings"></i> Slack configuration
-            </NavLink>
-          </NavItem>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'user-trigger-notification' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('user-trigger-notification') }}
-              href="#user-trigger-notification"
-            >
-              <i className="icon-settings"></i> User trigger notification
-            </NavLink>
-          </NavItem>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'global-notification' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('global-notification') }}
-              href="#global-notification"
-            >
-              <i className="icon-settings"></i> Global notification
-            </NavLink>
-          </NavItem>
-        </Nav>
-        <TabContent activeTab={activeTab}>
-          <TabPane tabId="slack-configuration">
-            {activeComponents.has('slack-configuration') && <SlackAppConfiguration />}
-          </TabPane>
-          <TabPane tabId="user-trigger-notification">
-            {activeComponents.has('user-trigger-notification') && <UserTriggerNotification />}
-          </TabPane>
-          <TabPane tabId="global-notification">
-            {activeComponents.has('global-notification') && <GlobalNotification />}
-          </TabPane>
-        </TabContent>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const NotificationSettingContentsWrapper = withUnstatedContainers(NotificationSettingContents, [AppContainer, AdminNotificationContainer]);
-
-NotificationSettingContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-
-};
-
-export default withTranslation()(NotificationSettingContentsWrapper);

+ 123 - 166
src/client/js/components/Admin/Security/SecurityManagementContents.jsx

@@ -1,13 +1,9 @@
-import React, { Fragment } from 'react';
+import React, { Fragment, useMemo, useState } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import {
-  TabContent, TabPane, Nav, NavItem, NavLink,
-} from 'reactstrap';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { TabContent, TabPane } from 'reactstrap';
 
-import AppContainer from '../../../services/AppContainer';
 import LdapSecuritySetting from './LdapSecuritySetting';
 import LocalSecuritySetting from './LocalSecuritySetting';
 import SamlSecuritySetting from './SamlSecuritySetting';
@@ -20,177 +16,138 @@ import TwitterSecuritySetting from './TwitterSecuritySetting';
 import FacebookSecuritySetting from './FacebookSecuritySetting';
 import ShareLinkSetting from './ShareLinkSetting';
 
-class SecurityManagementContents extends React.Component {
-
-  constructor() {
-    super();
-
-    this.state = {
-      activeTab: 'passport-local',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['passport-local']),
+import CustomNav from '../../CustomNavigation/CustomNav';
+
+function SecurityManagementContents(props) {
+  const { t } = props;
+
+  const [activeTab, setActiveTab] = useState('passport_local');
+  const [activeComponents, setActiveComponents] = useState(new Set(['passport_local']));
+
+  const switchActiveTab = (selectedTab) => {
+    setActiveTab(selectedTab);
+    setActiveComponents(activeComponents.add(selectedTab));
+  };
+
+  const navTabMapping = useMemo(() => {
+    return {
+      passport_local: {
+        Icon: () => <i className="fa fa-users" />,
+        i18n: 'ID/Pass',
+        index: 0,
+      },
+      passport_ldap: {
+        Icon: () => <i className="fa fa-sitemap" />,
+        i18n: 'LDAP',
+        index: 1,
+      },
+      passport_saml: {
+        Icon: () => <i className="fa fa-key" />,
+        i18n: 'SAML',
+        index: 2,
+      },
+      passport_oidc: {
+        Icon: () => <i className="fa fa-key" />,
+        i18n: 'OIDC',
+        index: 3,
+      },
+      passport_basic: {
+        Icon: () => <i className="fa fa-lock" />,
+        i18n: 'BASIC',
+        index: 4,
+      },
+      passport_google: {
+        Icon: () => <i className="fa fa-google" />,
+        i18n: 'Google',
+        index: 5,
+      },
+      passport_github: {
+        Icon: () => <i className="fa fa-github" />,
+        i18n: 'GitHub',
+        index: 6,
+      },
+      passport_twitter: {
+        Icon: () => <i className="fa fa-twitter" />,
+        i18n: 'Twitter',
+        index: 7,
+      },
+      passport_facebook: {
+        Icon: () => <i className="fa fa-facebook" />,
+        i18n: '(TBD) Facebook',
+        index: 8,
+      },
     };
+  }, []);
 
-    this.toggleActiveTab = this.toggleActiveTab.bind(this);
-  }
-
-  toggleActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-  render() {
-    const { t } = this.props;
-    const { activeTab, activeComponents } = this.state;
-    return (
-      <Fragment>
-        <div className="mb-5">
-          <SecuritySetting />
-        </div>
 
-        {/* Shared Link List */}
-        <div className="mb-5">
-          <ShareLinkSetting />
-        </div>
+  return (
+    <Fragment>
+      <div className="mb-5">
+        <SecuritySetting />
+      </div>
 
+      {/* Shared Link List */}
+      <div className="mb-5">
+        <ShareLinkSetting />
+      </div>
 
-        {/* XSS configuration link */}
-        <div className="mb-5">
-          <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>
-          <div className="text-center">
-            <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
-              <i className="fa-fw icon-login"></i> {t('security_setting.xss_prevent_setting_link')}
-            </a>
-          </div>
-        </div>
 
-        <div className="auth-mechanism-configurations">
-          <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
-          <Nav tabs>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-local' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-local') }}
-                href="#passport-local"
-              >
-                <i className="fa fa-users" /> ID/Pass
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-ldap' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-ldap') }}
-                href="#passport-ldap"
-              >
-                <i className="fa fa-sitemap" /> LDAP
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-saml' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-saml') }}
-                href="#passport-saml"
-              >
-                <i className="fa fa-key" /> SAML
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-oidc' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-oidc') }}
-                href="#passport-oidc"
-              >
-                <i className="fa fa-openid" /> OIDC
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-basic' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-basic') }}
-                href="#passport-basic"
-              >
-                <i className="fa fa-lock" /> BASIC
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-google' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-google') }}
-                href="#passport-google"
-              >
-                <i className="fa fa-google" /> Google
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-github' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-github') }}
-                href="#passport-github"
-              >
-                <i className="fa fa-github" /> GitHub
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-twitter' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-twitter') }}
-                href="#passport-twitter"
-              >
-                <i className="fa fa-twitter" /> Twitter
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-facebook' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-facebook') }}
-                href="#passport-facebook"
-              >
-                <i className="fa fa-facebook" /> (TBD) Facebook
-              </NavLink>
-            </NavItem>
-          </Nav>
-          <TabContent activeTab={activeTab} className="mt-2">
-            <TabPane tabId="passport-local">
-              {activeComponents.has('passport-local') && <LocalSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-ldap">
-              {activeComponents.has('passport-ldap') && <LdapSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-saml">
-              {activeComponents.has('passport-saml') && <SamlSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-oidc">
-              {activeComponents.has('passport-oidc') && <OidcSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-basic">
-              {activeComponents.has('passport-basic') && <BasicSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-google">
-              {activeComponents.has('passport-google') && <GoogleSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-github">
-              {activeComponents.has('passport-github') && <GitHubSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-twitter">
-              {activeComponents.has('passport-twitter') && <TwitterSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-facebook">
-              {activeComponents.has('passport-facebook') && <FacebookSecuritySetting />}
-            </TabPane>
-          </TabContent>
+      {/* XSS configuration link */}
+      <div className="mb-5">
+        <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>
+        <div className="text-center">
+          <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
+            <i className="fa-fw icon-login"></i> {t('security_setting.xss_prevent_setting_link')}
+          </a>
         </div>
-      </Fragment>
-    );
-  }
+      </div>
+
+      <div className="auth-mechanism-configurations">
+        <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
+        <CustomNav
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          onNavSelected={switchActiveTab}
+          hideBorderBottom
+          breakpointToSwitchDropdownDown="md"
+        />
+        <TabContent activeTab={activeTab} className="p-5">
+          <TabPane tabId="passport_local">
+            {activeComponents.has('passport_local') && <LocalSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_ldap">
+            {activeComponents.has('passport_ldap') && <LdapSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_saml">
+            {activeComponents.has('passport_saml') && <SamlSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_oidc">
+            {activeComponents.has('passport_oidc') && <OidcSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_basic">
+            {activeComponents.has('passport_basic') && <BasicSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_google">
+            {activeComponents.has('passport_google') && <GoogleSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_github">
+            {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_twitter">
+            {activeComponents.has('passport_twitter') && <TwitterSecuritySetting />}
+          </TabPane>
+          <TabPane tabId="passport_facebook">
+            {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />}
+          </TabPane>
+        </TabContent>
+      </div>
+    </Fragment>
+  );
 
 }
 
 SecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-const SecurityManagementContentsWrapper = withUnstatedContainers(SecurityManagementContents, [AppContainer]);
-
-export default withTranslation()(SecurityManagementContentsWrapper);
+export default withTranslation()(SecurityManagementContents);

+ 1 - 1
src/client/js/components/Admin/Security/SecuritySetting.jsx

@@ -63,7 +63,7 @@ class SecuritySetting extends React.Component {
               <td>{ t('always_hidden') }</td>
             </tr>
             <tr>
-              <th scope="row">{ t('Just me') }</th>
+              <th scope="row">{ t('Only me') }</th>
               <td>
                 <div className="custom-control custom-switch custom-checkbox-success">
                   <input

+ 53 - 32
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -11,7 +11,32 @@ import AppContainer from '../../../services/AppContainer';
 import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
-import ShareLinkList from '../../ShareLinkList';
+import ShareLinkList from '../../ShareLink/ShareLinkList';
+
+
+const Pager = (props) => {
+  if (props.links.length === 0) {
+    return null;
+  }
+  return (
+    <PaginationWrapper
+      activePage={props.activePage}
+      changePage={props.handlePage}
+      totalItemsCount={props.totalLinks}
+      pagingLimit={props.limit}
+      align="center"
+      size="sm"
+    />
+  );
+};
+
+Pager.propTypes = {
+  links: PropTypes.array.isRequired,
+  activePage: PropTypes.number.isRequired,
+  handlePage: PropTypes.func.isRequired,
+  totalLinks: PropTypes.number.isRequired,
+  limit: PropTypes.number.isRequired,
+};
 
 class ShareLinkSetting extends React.Component {
 
@@ -66,6 +91,7 @@ class ShareLinkSetting extends React.Component {
 
   async deleteLinkById(shareLinkId) {
     const { t, appContainer, adminGeneralSecurityContainer } = this.props;
+    const { shareLinksActivePage } = adminGeneralSecurityContainer.state;
 
     try {
       const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
@@ -76,54 +102,49 @@ class ShareLinkSetting extends React.Component {
       toastError(err);
     }
 
-    this.getShareLinkList(adminGeneralSecurityContainer.state.shareLinksActivePage);
+    this.getShareLinkList(shareLinksActivePage);
   }
 
 
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
+    const {
+      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+    } = adminGeneralSecurityContainer.state;
 
-    const pager = (
-      <div className="pull-right my-3">
-        <PaginationWrapper
-          activePage={adminGeneralSecurityContainer.state.shareLinksActivePage}
-          changePage={this.getShareLinkList}
-          totalItemsCount={adminGeneralSecurityContainer.state.totalshareLinks}
-          pagingLimit={adminGeneralSecurityContainer.state.shareLinksPagingLimit}
-        />
-      </div>
-    );
-
-    const deleteAllButton = (
-      adminGeneralSecurityContainer.state.shareLinks.length > 0
-        ? (
+    return (
+      <Fragment>
+        <div className="mb-3">
           <button
             className="pull-right btn btn-danger"
+            disabled={shareLinks.length === 0}
             type="button"
             onClick={this.showDeleteConfirmModal}
           >
             {t('share_links.delete_all_share_links')}
           </button>
-        )
-        : (
-          <p className="pull-right mr-2">{t('share_links.No_share_links')}</p>
-        )
-    );
-
-    return (
-      <Fragment>
-        <div className="mb-3">
-          {deleteAllButton}
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
-
-        {pager}
-        <ShareLinkList
-          shareLinks={adminGeneralSecurityContainer.state.shareLinks}
-          onClickDeleteButton={this.deleteLinkById}
-          isAdmin
+        <Pager
+          links={shareLinks}
+          activePage={shareLinksActivePage}
+          handlePage={this.getShareLinkList}
+          totalLinks={totalshareLinks}
+          limit={shareLinksPagingLimit}
         />
 
+        {(shareLinks.length !== 0) ? (
+          <ShareLinkList
+            shareLinks={shareLinks}
+            onClickDeleteButton={this.deleteLinkById}
+            isAdmin
+          />
+          )
+          : (<p className="text-center">{t('share_links.No_share_links')}</p>
+          )
+        }
+
+
         <DeleteAllShareLinksModal
           isOpen={this.state.isDeleteConfirmModalShown}
           onClose={this.closeDeleteConfirmModal}

+ 2 - 2
src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx

@@ -100,8 +100,8 @@ class UserGroupDeleteModal extends React.Component {
     const { t } = this.props;
 
     const optoins = this.availableOptions.map((opt) => {
-      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
-      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${opt.label}</span>`;
+      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{opt.label}</option>;
     });
 
     return (

+ 11 - 6
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -152,12 +152,17 @@ class UserGroupPage extends React.Component {
           onDelete={this.showDeleteModal}
           userGroupRelations={this.state.userGroupRelations}
         />
-        <PaginationWrapper
-          activePage={this.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={this.state.totalUserGroups}
-          pagingLimit={this.state.pagingLimit}
-        />
+        {this.state.userGroups.length === 0
+        ? <p>No groups yet</p> : (
+          <PaginationWrapper
+            activePage={this.state.activePage}
+            changePage={this.handlePage}
+            totalItemsCount={this.state.totalUserGroups}
+            pagingLimit={this.state.pagingLimit}
+            align="center"
+            size="sm"
+          />
+        )}
         <UserGroupDeleteModal
           userGroups={this.state.userGroups}
           deleteUserGroup={this.state.selectedUserGroup}

+ 12 - 8
src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -40,7 +40,7 @@ class UserGroupPageList extends React.Component {
       const { total, pages } = res.data;
 
       this.setState({
-        total: total || 0,
+        total,
         activePage: pageNum,
         currentPages: pages,
       });
@@ -52,19 +52,23 @@ class UserGroupPageList extends React.Component {
 
   render() {
     const { t, adminUserGroupDetailContainer } = this.props;
+    const { relatedPages } = adminUserGroupDetailContainer.state;
 
     return (
       <Fragment>
         <ul className="page-list-ul page-list-ul-flat mb-3">
           {this.state.currentPages.map(page => <li key={page._id}><Page page={page} /></li>)}
         </ul>
-        {adminUserGroupDetailContainer.state.relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : null}
-        <PaginationWrapper
-          activePage={this.state.activePage}
-          changePage={this.handlePageChange}
-          totalItemsCount={this.state.total}
-          pagingLimit={this.state.pagingLimit}
-        />
+        {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
+          <PaginationWrapper
+            activePage={this.state.activePage}
+            changePage={this.handlePageChange}
+            totalItemsCount={this.state.total}
+            pagingLimit={this.state.pagingLimit}
+            align="center"
+            size="sm"
+          />
+        )}
       </Fragment>
     );
   }

+ 3 - 1
src/client/js/components/Admin/UserManagement.jsx

@@ -114,12 +114,14 @@ class UserManagement extends React.Component {
     const { t, adminUsersContainer } = this.props;
 
     const pager = (
-      <div className="pull-right my-3">
+      <div className="my-3">
         <PaginationWrapper
           activePage={adminUsersContainer.state.activePage}
           changePage={this.handlePage}
           totalItemsCount={adminUsersContainer.state.totalUsers}
           pagingLimit={adminUsersContainer.state.pagingLimit}
+          align="center"
+          size="sm"
         />
       </div>
     );

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

@@ -131,9 +131,9 @@ class UserInviteModal extends React.Component {
         {userList.map((user) => {
           const copyText = `Email:${user.email} Password:${user.password} `;
           return (
-            <div className="my-1">
-              <CopyToClipboard key={user.email} text={copyText} onCopy={this.showToaster}>
-                <li key={user.email} className="btn btn-outline-secondary">
+            <div className="my-1" key={user.email}>
+              <CopyToClipboard text={copyText} onCopy={this.showToaster}>
+                <li className="btn btn-outline-secondary">
                 Email: <strong className="mr-3">{user.email}</strong> Password: <strong>{user.password}</strong>
                 </li>
               </CopyToClipboard>

+ 9 - 6
src/client/js/components/Admin/Users/UserMenu.jsx

@@ -1,6 +1,9 @@
 import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import {
+  UncontrolledDropdown, DropdownToggle, DropdownMenu,
+} from 'reactstrap';
 
 import StatusActivateButton from './StatusActivateButton';
 import StatusSuspendedButton from './StatusSuspendedButton';
@@ -80,16 +83,16 @@ class UserMenu extends React.Component {
 
     return (
       <Fragment>
-        <div className="btn-group admin-user-menu" role="group">
-          <button id="userMenu" type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
+        <UncontrolledDropdown id="userMenu" size="sm">
+          <DropdownToggle caret color="secondary" outline>
             <i className="icon-settings"></i>
-          </button>
-          <div className="dropdown-menu" aria-labelledby="userMenu">
+          </DropdownToggle>
+          <DropdownMenu positionFixed>
             {this.renderEditMenu()}
             {user.status !== 4 && this.renderStatusMenu()}
             {user.status === 2 && this.renderAdminMenu()}
-          </div>
-        </div>
+          </DropdownMenu>
+        </UncontrolledDropdown>
       </Fragment>
     );
   }

+ 44 - 47
src/client/js/components/BookmarkButton.jsx

@@ -1,83 +1,80 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { UncontrolledTooltip } from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+
 import { toastError } from '../util/apiNotification';
+import PageContainer from '../services/PageContainer';
+import AppContainer from '../services/AppContainer';
 
 class BookmarkButton extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-      isBookmarked: false,
-    };
-
     this.handleClick = this.handleClick.bind(this);
   }
 
-  async componentDidMount() {
-    const { pageId, crowi } = this.props;
-    // if guest user
-    if (!this.isUserLoggedIn()) {
-      // do nothing
-      return;
-    }
+  async handleClick() {
+    const { appContainer, pageContainer } = this.props;
+    const { isGuestUser } = appContainer;
 
-    try {
-      const response = await crowi.apiv3.get('/bookmarks', { pageId });
-      if (response.data.bookmark != null) {
-        this.setState({ isBookmarked: true });
-      }
-    }
-    catch (err) {
-      toastError(err);
+    if (isGuestUser) {
+      return;
     }
 
-  }
-
-  async handleClick() {
-    const { crowi, pageId } = this.props;
-    const bool = !this.state.isBookmarked;
-
     try {
-      await crowi.apiv3.put('/bookmarks', { pageId, bool });
-      this.setState({ isBookmarked: bool });
+      pageContainer.toggleBookmark();
     }
     catch (err) {
       toastError(err);
     }
   }
 
-  isUserLoggedIn() {
-    return this.props.crowi.currentUserId != null;
-  }
 
   render() {
-    // if guest user
-    if (!this.isUserLoggedIn()) {
-      return <div></div>;
-    }
+    const { appContainer, pageContainer, t } = this.props;
+    const { isGuestUser } = appContainer;
 
     return (
-      <button
-        type="button"
-        href="#"
-        title="Bookmark"
-        onClick={this.handleClick}
-        className={`btn rounded-circle btn-bookmark border-0 d-edit-none
-          ${`btn-${this.props.size}`}
-          ${this.state.isBookmarked ? 'active' : ''}`}
-      >
-        <i className="icon-star"></i>
-      </button>
+      <div>
+        <button
+          type="button"
+          id="bookmark-button"
+          onClick={this.handleClick}
+          className={`btn btn-bookmark border-0
+          ${`btn-${this.props.size}`} ${pageContainer.state.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+        >
+          <i className="icon-star mr-3"></i>
+          <span className="total-bookmarks">
+            {pageContainer.state.sumOfBookmarks}
+          </span>
+        </button>
+
+        {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+        )}
+      </div>
     );
   }
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer, PageContainer]);
+
 BookmarkButton.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   pageId: PropTypes.string,
-  crowi: PropTypes.object.isRequired,
+  t: PropTypes.func.isRequired,
   size: PropTypes.string,
 };
 
@@ -85,4 +82,4 @@ BookmarkButton.defaultProps = {
   size: 'md',
 };
 
-export default BookmarkButton;
+export default withTranslation()(BookmarkButtonWrapper);

+ 231 - 0
src/client/js/components/CustomNavigation/CustomNav.jsx

@@ -0,0 +1,231 @@
+import React, {
+  useEffect, useState, useRef, useMemo, useCallback,
+} from 'react';
+import PropTypes from 'prop-types';
+import {
+  Nav, NavItem, NavLink,
+} from 'reactstrap';
+
+
+function getBreakpointOneLevelLarger(breakpoint) {
+  switch (breakpoint) {
+    case 'sm':
+      return 'md';
+    case 'md':
+      return 'lg';
+    case 'lg':
+      return 'xl';
+    case 'xl':
+    default:
+      return '2xl';
+  }
+}
+
+
+export const CustomNavDropdown = (props) => {
+  const {
+    activeTab, navTabMapping, onNavSelected,
+  } = props;
+
+  const activeObj = navTabMapping[activeTab];
+
+  const menuItemClickHandler = useCallback((key) => {
+    if (onNavSelected != null) {
+      onNavSelected(key);
+    }
+  }, [onNavSelected]);
+
+  return (
+    <div className="grw-custom-nav-dropdown btn-group btn-block">
+      <button
+        className="btn btn-outline-primary btn-lg btn-block dropdown-toggle text-right"
+        type="button"
+        data-toggle="dropdown"
+        aria-haspopup="true"
+        aria-expanded="false"
+      >
+        <span className="float-left">
+          { activeObj != null && (
+            <><activeObj.Icon /> {activeObj.i18n}</>
+          ) }
+        </span>
+      </button>
+      <div className="dropdown-menu dropdown-menu-right">
+        {Object.entries(navTabMapping).map(([key, value]) => {
+
+          const isActive = activeTab === key;
+          const isLinkEnabled = value.isLinkEnabled != null ? value.isLinkEnabled(value) : true;
+          const { Icon, i18n } = value;
+
+          return (
+            <button
+              key={key}
+              type="button"
+              className={`dropdown-item px-3 py-2 ${isActive ? 'active' : ''}`}
+              disabled={!isLinkEnabled}
+              onClick={() => menuItemClickHandler(key)}
+            >
+              <Icon /> {i18n}
+            </button>
+          );
+        })}
+      </div>
+    </div>
+  );
+};
+
+CustomNavDropdown.propTypes = {
+  activeTab: PropTypes.string.isRequired,
+  navTabMapping: PropTypes.object.isRequired,
+  onNavSelected: PropTypes.func,
+};
+
+
+export const CustomNavTab = (props) => {
+  const navContainer = useRef();
+  const [sliderWidth, setSliderWidth] = useState(0);
+  const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
+
+  const {
+    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown,
+  } = props;
+
+  const navTabRefs = useMemo(() => {
+    const obj = {};
+    Object.keys(navTabMapping).forEach((key) => {
+      obj[key] = React.createRef();
+    });
+    return obj;
+  }, [navTabMapping]);
+
+  const navLinkClickHandler = useCallback((key) => {
+    if (onNavSelected != null) {
+      onNavSelected(key);
+    }
+  }, [onNavSelected]);
+
+  function registerNavLink(key, elm) {
+    if (elm != null) {
+      navTabRefs[key] = elm;
+    }
+  }
+
+  // Might make this dynamic for px, %, pt, em
+  function getPercentage(min, max) {
+    return min / max * 100;
+  }
+
+  useEffect(() => {
+    if (activeTab === '') {
+      return;
+    }
+
+    if (navContainer == null) {
+      return;
+    }
+
+    let tempML = 0;
+
+    const styles = Object.entries(navTabRefs).map((el) => {
+      const width = getPercentage(el[1].offsetWidth, navContainer.current.offsetWidth);
+      const marginLeft = tempML;
+      tempML += width;
+      return { width, marginLeft };
+    });
+    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
+
+    setSliderWidth(width);
+    setSliderMarginLeft(marginLeft);
+
+  }, [activeTab, navTabRefs, navTabMapping]);
+
+  // determine inactive classes to hide NavItem
+  const inactiveClassnames = [];
+  if (breakpointToHideInactiveTabsDown != null) {
+    const breakpointOneLevelLarger = getBreakpointOneLevelLarger(breakpointToHideInactiveTabsDown);
+    inactiveClassnames.push('d-none');
+    inactiveClassnames.push(`d-${breakpointOneLevelLarger}-block`);
+  }
+
+  return (
+    <div className="grw-custom-nav-tab">
+      <div ref={navContainer}>
+        <Nav className="nav-title">
+          {Object.entries(navTabMapping).map(([key, value]) => {
+
+            const isActive = activeTab === key;
+            const isLinkEnabled = value.isLinkEnabled != null ? value.isLinkEnabled(value) : true;
+            const { Icon, i18n } = value;
+
+            return (
+              <NavItem
+                key={key}
+                className={`p-0 ${isActive ? 'active' : inactiveClassnames.join(' ')}`}
+              >
+                <NavLink type="button" key={key} innerRef={elm => registerNavLink(key, elm)} disabled={!isLinkEnabled} onClick={() => navLinkClickHandler(key)}>
+                  <Icon /> {i18n}
+                </NavLink>
+              </NavItem>
+            );
+          })}
+        </Nav>
+      </div>
+      <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
+      { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
+    </div>
+  );
+
+};
+
+CustomNavTab.propTypes = {
+  activeTab: PropTypes.string.isRequired,
+  navTabMapping: PropTypes.object.isRequired,
+  onNavSelected: PropTypes.func,
+  hideBorderBottom: PropTypes.bool,
+  breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+};
+
+CustomNavTab.defaultProps = {
+  hideBorderBottom: false,
+};
+
+
+const CustomNav = (props) => {
+
+  const tabClassnames = ['d-none'];
+  const dropdownClassnames = ['d-block'];
+
+  // determine classes to show/hide
+  const breakpointOneLevelLarger = getBreakpointOneLevelLarger(props.breakpointToSwitchDropdownDown);
+  tabClassnames.push(`d-${breakpointOneLevelLarger}-block`);
+  dropdownClassnames.push(`d-${breakpointOneLevelLarger}-none`);
+
+  return (
+    <div className="grw-custom-nav">
+      <div className={tabClassnames.join(' ')}>
+        <CustomNavTab {...props} />
+      </div>
+      <div className={dropdownClassnames.join(' ')}>
+        <CustomNavDropdown {...props} />
+      </div>
+    </div>
+  );
+
+};
+
+CustomNav.propTypes = {
+  activeTab: PropTypes.string.isRequired,
+  navTabMapping: PropTypes.object.isRequired,
+  onNavSelected: PropTypes.func,
+  hideBorderBottom: PropTypes.bool,
+  breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  breakpointToSwitchDropdownDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+};
+
+CustomNav.defaultProps = {
+  hideBorderBottom: false,
+  breakpointToSwitchDropdownDown: 'sm',
+};
+
+
+export default CustomNav;

+ 52 - 0
src/client/js/components/CustomNavigation/CustomNavAndContents.jsx

@@ -0,0 +1,52 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
+import CustomTabContent from './CustomTabContent';
+
+
+const CustomNavAndContents = (props) => {
+  const {
+    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown,
+  } = props;
+  const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
+
+  let SelectedNav;
+  switch (navigationMode) {
+    case 'tab':
+      SelectedNav = CustomNavTab;
+      break;
+    case 'dropdown':
+      SelectedNav = CustomNavDropdown;
+      break;
+    case 'both':
+      SelectedNav = CustomNav;
+      break;
+  }
+
+  return (
+    <>
+      <SelectedNav
+        activeTab={activeTab}
+        navTabMapping={navTabMapping}
+        onNavSelected={setActiveTab}
+        breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
+      />
+      <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
+    </>
+  );
+};
+
+CustomNavAndContents.propTypes = {
+  navTabMapping: PropTypes.object.isRequired,
+  defaultTabIndex: PropTypes.number,
+  navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
+  tabContentClasses: PropTypes.arrayOf(PropTypes.string),
+  breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+};
+CustomNavAndContents.defaultProps = {
+  navigationMode: 'tab',
+  tabContentClasses: ['p-4'],
+};
+
+export default CustomNavAndContents;

+ 37 - 0
src/client/js/components/CustomNavigation/CustomTabContent.jsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  TabContent, TabPane,
+} from 'reactstrap';
+
+const CustomTabContent = (props) => {
+
+  const { activeTab, navTabMapping, additionalClassNames } = props;
+
+  return (
+    <TabContent activeTab={activeTab} className={additionalClassNames.join(' ')}>
+      {Object.entries(navTabMapping).map(([key, value]) => {
+
+        const { Content } = value;
+
+        return (
+          <TabPane key={key} tabId={key}>
+            <Content />
+          </TabPane>
+        );
+      })}
+    </TabContent>
+  );
+
+};
+
+CustomTabContent.propTypes = {
+  activeTab: PropTypes.string.isRequired,
+  navTabMapping: PropTypes.object.isRequired,
+  additionalClassNames: PropTypes.arrayOf(PropTypes.string),
+};
+CustomTabContent.defaultProps = {
+  additionalClassNames: [],
+};
+
+export default CustomTabContent;

+ 18 - 0
src/client/js/components/Drawio.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { debounce } from 'throttle-debounce';
+
 import { withTranslation } from 'react-i18next';
 
 import AppContainer from '../services/AppContainer';
@@ -26,6 +28,9 @@ class Drawio extends React.Component {
     this.drawioContent = this.props.drawioContent;
 
     this.onEdit = this.onEdit.bind(this);
+
+    // create debounced method for rendering Drawio
+    this.renderDrawioWithDebounce = debounce(200, this.renderDrawio);
   }
 
   onEdit() {
@@ -35,6 +40,16 @@ class Drawio extends React.Component {
   }
 
   componentDidMount() {
+    const DrawioViewer = window.GraphViewer;
+    if (DrawioViewer != null) {
+      this.renderDrawio();
+    }
+    else {
+      this.renderDrawioWithDebounce();
+    }
+  }
+
+  renderDrawio() {
     const DrawioViewer = window.GraphViewer;
     if (DrawioViewer != null) {
       const mxgraphs = this.drawioContainer.getElementsByClassName('mxgraph');
@@ -48,6 +63,9 @@ class Drawio extends React.Component {
         }
       }
     }
+    else {
+      this.renderDrawioWithDebounce();
+    }
   }
 
   renderContents() {

+ 33 - 0
src/client/js/components/ExpandOrContractButton.jsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function ExpandOrContractButton(props) {
+  const { isWindowExpanded, contractWindow, expandWindow } = props;
+
+  const clickContractButtonHandler = () => {
+    if (contractWindow != null) {
+      contractWindow();
+    }
+  };
+
+  const clickExpandButtonHandler = () => {
+    if (expandWindow != null) {
+      expandWindow();
+    }
+  };
+
+  return (
+    <button type="button" className="close" onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}>
+      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
+    </button>
+  );
+}
+
+ExpandOrContractButton.propTypes = {
+  isWindowExpanded: PropTypes.bool,
+  contractWindow: PropTypes.func,
+  expandWindow: PropTypes.func,
+};
+
+
+export default ExpandOrContractButton;

+ 12 - 4
src/client/js/components/Fab.jsx

@@ -17,13 +17,17 @@ const Fab = (props) => {
   const { currentUser } = appContainer;
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
+  const [buttonClasses, setButtonClasses] = useState('');
 
 
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
 
-    const classes = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
-    setAnimateClasses(classes);
+    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
@@ -47,7 +51,7 @@ const Fab = (props) => {
         <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
           <button
             type="button"
-            className="btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light"
+            className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
             onClick={navigationContainer.openPageCreateModal}
           >
             <CreatePageIcon />
@@ -61,7 +65,11 @@ const Fab = (props) => {
     <div className="grw-fab d-none d-md-block">
       {currentUser != null && renderPageCreateButton()}
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
-        <button type="button" className="btn btn-light btn-scroll-to-top rounded-circle p-0" onClick={() => navigationContainer.smoothScrollIntoView()}>
+        <button
+          type="button"
+          className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
+          onClick={() => navigationContainer.smoothScrollIntoView()}
+        >
           <ReturnTopIcon />
         </button>
       </div>

+ 31 - 0
src/client/js/components/FootstampIcon.jsx

@@ -0,0 +1,31 @@
+import React from 'react';
+
+const FootstampIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="16"
+    height="16"
+    viewBox="0 0 16 16"
+  >
+    <path d="M7.34,8,3.31,9a1.83,1.83,0,0,1-1.24-.08A1.28,1.28,0,0,1,1.34,8a3.24,3.24,0,0,1,.2-1.82A6.06,6.06,0,0,1,2.6,4.35h0a2.56,
+    2.56,0,0,1,3.34-.77A5.65,5.65,0,0,1,7.69,4.73a3.23,3.23,0,0,1,1,1.53A1.29,1.29,0,0,1,8.42,7.4,1.86,1.86,0,0,1,7.34,8Zm-3-3.82a2.17,2.17,0,0,
+    0-1.05.74h0a4.75,4.75,0,0,0-.89,1.52,2.37,2.37,0,0,0-.17,1.3.38.38,0,0,0,.23.31,1,1,0,0,0,.65,
+      0l4-.94a1,1,0,0,0,.58-.3.39.39,0,0,0,.07-.38,2.32,2.32,0,0,0-.73-1.08,4.7,4.7,0,0,0-1.47-1A2.07,2.07,0,0,0,4.33,4.2Z"
+    />
+    <path d="M7.26,1.39a.57.57,0,0,0-.18,0,.81.81,0,0,0-.61,1l.09.38a.81.81,0,0,0,.79.63l.19,0a.82.82,0,0,0,.6-1L8.05,2a.81.81,0,0,0-.79-.63Z" />
+    <path d="M.81,2.9a.55.55,0,0,0-.18,0h0a.81.81,0,0,0-.61,1l.09.38A.81.81,0,0,0,.9,4.9l.18,0h0a.82.82,0,0,0,.61-1L1.6,3.52A.8.8,0,0,0,.81,2.9Z" />
+    <path d="M2.29.61a.57.57,0,0,0-.18,0,.81.81,0,0,0-.61,1l.16.7a.81.81,0,0,0,.79.63l.19,0h0a.8.8,0,0,0,.6-1l-.16-.71A.82.82,0,0,0,2.29.61Z" />
+    <path d="M4.93,0,4.75,0a.82.82,0,0,0-.61,1l.16.7a.82.82,0,0,0,.79.63l.19,0h0a.82.82,0,0,0,.61-1L5.72.63A.81.81,0,0,0,4.93,0Z" />
+    <path d="M13.22,16l-4.1-.54A1.88,1.88,0,0,1,8,14.94a1.34,1.34,0,0,1-.36-1.12,3.19,3.19,0,0,1,.83-1.62,5.73,5.73,0,0,1,1.62-1.32h0a2.57,2.57,
+    0,0,1,3.4.44A5.82,5.82,0,0,1,14.7,13a3.21,3.21,0,0,1,.38,1.78,1.28,1.28,0,0,1-.63,1A1.94,1.94,0,0,1,13.22,16Zm-1.48-4.64a2.12,2.12,0,0,
+    0-1.24.33h0a5.07,5.07,0,0,0-1.37,1.11,2.41,2.41,0,0,0-.62,1.16.43.43,0,0,0,.11.37,1.08,1.08,0,0,0,.61.24l4.11.53A1,1,0,0,0,14,15a.41.41,0,0,
+    0,.2-.33,2.47,2.47,0,0,0-.3-1.28,5,5,0,0,0-1-1.42A2.12,2.12,0,0,0,11.74,11.34Z"
+    />
+    <path d="M15.19,9.69a.82.82,0,0,0-.81.71l-.05.39a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.7l.05-.39a.8.8,0,0,0-.7-.91Z" />
+    <path d="M8.62,8.84a.82.82,0,0,0-.81.7l0,.39a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.71l.06-.39a.82.82,0,0,0-.7-.91Z" />
+    <path d="M10.8,7.22a.81.81,0,0,0-.8.7l-.09.72a.81.81,0,0,0,.7.91h.1a.83.83,0,0,0,.81-.71l.09-.72a.82.82,0,0,0-.7-.91Z" />
+    <path d="M13.49,7.57a.81.81,0,0,0-.8.71l-.1.71a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.71l.1-.71a.81.81,0,0,0-.7-.91Z" />
+  </svg>
+);
+
+export default FootstampIcon;

+ 54 - 0
src/client/js/components/ForbiddenPage.jsx

@@ -0,0 +1,54 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import PageListIcon from './Icons/PageListIcon';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
+import PageList from './PageList';
+
+
+const ForbiddenPage = (props) => {
+  const { t } = props;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: PageList,
+        i18n: t('page_list'),
+        index: 0,
+      },
+    };
+  }, [t]);
+
+  return (
+    <>
+      <div className="row not-found-message-row mb-4">
+        <div className="col-lg-12">
+          <h2 className="text-muted">
+            <i className="icon-ban mr-2" aria-hidden="true" />
+            Forbidden
+          </h2>
+        </div>
+      </div>
+
+
+      <div className="row row-alerts d-edit-none">
+        <div className="col-sm-12">
+          <p className="alert alert-primary py-3 px-4">
+            <i className="icon-fw icon-lock" aria-hidden="true" />
+            {t('Browsing of this page is restricted')}
+          </p>
+        </div>
+      </div>
+      <div className="mt-5">
+        <CustomNavAndContents navTabMapping={navTabMapping} />
+      </div>
+    </>
+  );
+};
+
+ForbiddenPage.propTypes = {
+  t: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(ForbiddenPage);

+ 10 - 4
src/client/js/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,6 +1,9 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
+import NavigationContainer from '../../../services/NavigationContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 const EditPage = (props) => {
 
   // setup effect
@@ -9,8 +12,8 @@ const EditPage = (props) => {
     if (document.getElementsByClassName('modal in').length > 0) {
       return;
     }
-    // show editor
-    $('a[data-toggle="tab"][href="#edit"]').tab('show');
+
+    props.navigationContainer.setEditorMode('edit');
 
     // remove this
     props.onDeleteRender(this);
@@ -20,11 +23,14 @@ const EditPage = (props) => {
 };
 
 EditPage.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-EditPage.getHotkeyStrokes = () => {
+const EditPageWrapper = withUnstatedContainers(EditPage, [NavigationContainer]);
+
+EditPageWrapper.getHotkeyStrokes = () => {
   return [['e']];
 };
 
-export default EditPage;
+export default EditPageWrapper;

+ 27 - 0
src/client/js/components/Icons/AttachmentIcon.jsx

@@ -0,0 +1,27 @@
+import React from 'react';
+
+const Attachment = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <g className="cls-1">
+      <path
+        d="M2.9,13a2,2,0,0,1-1.44-.63,2.28,2.28,0,0,1,0-3.23l7-7.38a2.48,2.48,0,0,1,1.22-.7,2.61,
+        2.61,0,0,1,1.41.09A3.46,3.46,0,0,1,12.37,2a3.94,3.94,0,0,1,.36.45A2.61,2.61,0,0,1,13,3a3.41,3.41,
+        0,0,1,.16.57,3.06,3.06,0,0,1-.82,2.75L7.07,11.86a.35.35,0,0,1-.26.13.4.4,0,0,1-.28-.1.47.47,0,0,
+        1-.12-.27.39.39,0,0,1,.11-.29l5.26-5.59a2.28,2.28,0,0,0,.65-1.62,2.07,2.07,0,0,0-.62-1.58A2.62,2.62,
+        0,0,0,11,1.93a2,2,0,0,0-1-.13,1.63,1.63,0,0,0-1,.5L2,9.67a1.52,1.52,0,0,0,0,2.16,1.28,1.28,0,0,0,
+        .44.3,1,1,0,0,0,.51.08,1.43,1.43,0,0,0,1-.49L9.49,5.84l.12-.13.11-.15a1.24,1.24,0,0,0,.1-.2,1.94,
+        1.94,0,0,0,0-.2.6.6,0,0,0,0-.22.66.66,0,0,0-.14-.2.57.57,0,0,0-.45-.22,1,1,0,0,0-.52.3L4.56,
+        9.25a.42.42,0,0,1-.17.1.34.34,0,0,1-.2,0A.4.4,0,0,1,4,9.26.34.34,0,0,1,3.89,9,.41.41,0,0,1,4,8.72L8.16,
+        4.28a1.7,1.7,0,0,1,1-.53,1.32,1.32,0,0,1,1.06.43,1.23,1.23,0,0,1,.4,1.05,1.8,1.8,0,0,1-.58,1.14L4.52,
+        12.26A2.3,2.3,0,0,1,3,13H2.9Z"
+      />
+    </g>
+  </svg>
+);
+
+export default Attachment;

+ 28 - 0
src/client/js/components/Icons/BookmarkIcon.jsx

@@ -0,0 +1,28 @@
+import React from 'react';
+
+const BookmarkIcon = () => (
+
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="20"
+    height="20"
+    viewBox="0 0 20 20"
+  >
+
+    <g transform="translate(-925.888 168.873)">
+      <rect width="20" height="20" transform="translate(925.888 -168.873)" fill="none" />
+      <path d="M936.092-168.527a1.141,1.141,0,0,1,.205.039,1.685,1.685,0,0,1,.185.068c.058.026.116.056.175.088a1.038,1.038,0,0,1,
+        .166.117,1.826,1.826,0,0,1,.146.146c.045.052.088.1.127.156a.8.8,0,0,1,.1.175l2.26,4.7,5.2.76a1.424,1.424,0,0,1,.7.311,1.413,
+        1.413,0,0,1,.449.643,1.294,1.294,0,0,1-.351,1.423l-3.8,3.8.876,5.28a1.225,1.225,0,0,1-.088.76,1.451,1.451,0,0,1-.5.6,1.456,
+        1.456,0,0,1-.838.253,1.614,1.614,0,0,1-.351-.039,1.316,1.316,0,0,1-.35-.137l-4.52-2.435-4.54,2.435a1.37,1.37,0,0,1-.682.176h-.156a.525.525,
+        0,0,1-.146-.02l-.137-.039a1.117,1.117,0,0,1-.136-.049,1.231,1.231,0,0,1-.136-.068c-.046-.026-.088-.052-.127-.077a1.462,1.462,
+        0,0,1-.5-.6,1.232,1.232,0,0,1-.087-.76l.877-5.28-3.8-3.8a1.29,1.29,0,0,1-.35-1.423,1.4,1.4,0,0,1,.448-.643,1.423,1.423,0,0,1,
+        .7-.311l5.2-.76,2.26-4.7a1.351,1.351,0,0,1,.526-.584,1.467,1.467,0,0,1,.78-.215C935.953-168.537,936.02-168.533,936.092-168.527Zm-2.49,
+        5.9-.41.84-6.1.9,4.415,4.415-.136.879-.9,5.275,5.412-2.891,5.411,2.891-.9-5.275-.137-.879,4.415-4.415-6.115-.9-2.676-5.587Z"
+      />
+    </g>
+  </svg>
+
+);
+
+export default BookmarkIcon;

+ 0 - 0
src/client/js/components/GrowiLogo.jsx → src/client/js/components/Icons/GrowiLogo.jsx


+ 21 - 0
src/client/js/components/Icons/HistoryIcon.jsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+const RecentChanges = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path
+      d="M7.94.94A6.13,6.13,0,0,0,1.89,7v.1L.67,5.89a.38.38,0,0,0-.55,0,.39.39,0,0,0,0,.56L2.36,8.69,4.6,6.45a.4.4,0,0,0,0-.56.39.39,0,0,0-.56,
+      0L2.68,7.25V7A5.33,5.33,0,0,1,7.94,1.73,5.33,5.33,0,0,1,13.21,7a5.34,5.34,0,0,1-5.27,5.27H7.86A5,5,0,0,1,4,10.38a.4.4,0,0,0-.55-.07.4.4,0,
+      0,0-.07.56,5.83,5.83,0,0,0,4.52,2.19H8A6.13,6.13,0,0,0,14,7,6.13,6.13,0,0,0,7.94.94Z"
+    />
+    <path
+      d="M7.94,2.83a.4.4,0,0,0-.39.4V7.37L10,8.92a.37.37,0,0,0,.21.06.4.4,0,0,0,.21-.73L8.34,6.93V3.23A.4.4,0,0,0,7.94,2.83Z"
+    />
+  </svg>
+);
+
+export default RecentChanges;

+ 17 - 0
src/client/js/components/Icons/PageListIcon.jsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+const PageList = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path d="M12.63,2.72H1.37a.54.54,0,0,1,0-1.08H12.63a.54.54,0,0,1,0,1.08Z" />
+    <path d="M11.82,5.94H1.37a.55.55,0,0,1,0-1.09H11.82a.55.55,0,1,1,0,1.09Z" />
+    <path d="M9.41,9.15h-8a.54.54,0,0,1,0-1.08h8a.54.54,0,0,1,0,1.08Z" />
+    <path d="M10.84,12.36H1.37a.54.54,0,1,1,0-1.08h9.47a.54.54,0,1,1,0,1.08Z" />
+  </svg>
+);
+
+export default PageList;

+ 16 - 0
src/client/js/components/Icons/PagePreviewIcon.jsx

@@ -0,0 +1,16 @@
+import React from 'react';
+
+const PagePreviewIcon = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
+    <defs></defs>
+    <rect width="23" height="23" fillOpacity="0" />
+    <path d="M10.94,20.33H3.4V1.38H8.82V8.82h7.44v1.35a6.16,6.16,0,0,1,1.35.47V6.79L10.85,0H3.4a1.3,1.3,0,0,0-1,.39,1.3,1.3,0,0,0-.39,1v19A1.33,
+  1.33,0,0,0,3.4,21.68h9.84A5.94,5.94,0,0,1,10.94,20.33ZM10.17,1.38h.13l6,6v.11H10.17Z"
+    />
+    <path d="M21.87,22.14,18.75,19a4.74,4.74,0,0,0,1.1-3,4.89,4.89,0,1,0-1.8,3.73l3.11,3.11a.5.5,0,0,0,.35.15.51.51,0,0,0,.36-.15A.5.5,
+  0,0,0,21.87,22.14ZM15,19.57A3.57,3.57,0,1,1,18.59,16,3.58,3.58,0,0,1,15,19.57Z"
+    />
+  </svg>
+);
+
+export default PagePreviewIcon;

+ 22 - 0
src/client/js/components/Icons/PresentationIcon.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+
+const PresentationIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="14"
+    height="14"
+    viewBox="0 0 12.25 14"
+  >
+    <path
+      d="M44.261,0H32.909a.448.448,0,0,0-.449.448V7.635a.449.449,0,0,0,.9,0V.9H43.812V7.635a.449.449,0,0,0,.9,0V.448A.448.448,0,0,0,44.261,0Z"
+      transform="translate(-32.46)"
+    />
+    <path
+      d="M90.959,287.182H82.315a.448.448,0,1,0,0,.9h3.873v1.115l-3.207,3.381a.449.449,0,0,0,.652.616l2.555-2.694v2.013a.449.449,0,0,0,.9,0V
+        290.5l2.555,2.694a.449.449,0,0,0,.652-.616l-3.208-3.382v-1.114h3.873a.448.448,0,1,0,0-.9Z"
+      transform="translate(-80.512 -279.329)"
+    />
+  </svg>
+);
+
+export default PresentationIcon;

+ 44 - 0
src/client/js/components/Icons/RecentlyCreatedIcon.jsx

@@ -0,0 +1,44 @@
+import React from 'react';
+
+const RecentlyCreatedIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="20"
+    height="20"
+    viewBox="0 0 20 20"
+  >
+
+    <g transform="translate(-921.906 192.966)">
+
+      <rect
+        width="20"
+        height="20"
+        transform="translate(921.906 -192.966)"
+        fill="none"
+      />
+      <path
+        d="M933.752-189.286l.022-.009a3.3,3.3,0,0,1,1.556.927,2.991,2.991,0,0,1,.505.679,3.659,3.659,0,0,1,
+        .265.572c.038.126.069.245.091.356l-.911.9a6.484,6.484,0,0,1,1.086-.1c.177,0,.35.013.523.027.573-.571.93-.928,1.043-1.047a2.94,
+        2.94,0,0,0,.959-2.086,2.854,2.854,0,0,0-1.008-1.986,3.3,3.3,0,0,0-.9-.629,2.344,2.344,0,0,0-.986-.215,
+        2.836,2.836,0,0,0-2.053.91q-.3.28-10.478,10.478a.656.656,0,0,0-.149.232q-.066.28-1.391,4.651a.529.529,0,0,0,
+        .149.546c.036.032.084.073.1.086a.937.937,0,0,0,.124.057.585.585,0,0,0,.3-.007q3.493-1.147,4.57-1.461a.549.549,0,0,0,.124-.048.517.517,
+        0,0,0,.108-.083q.958-.952,2.5-2.483a2.017,2.017,0,0,0,.035-.513,6.356,6.356,0,0,1,.107-1.143l-2.558,2.531a4.537,4.537,0,0,0-.91-1.357,
+        4.672,4.672,0,0,0-1.556-1.043Zm.975-.953.033-.032a2.254,2.254,0,0,1,.207-.183,2.379,2.379,0,0,1,.447-.248,1.51,1.51,0,0,1,.637-.149,
+        1.418,1.418,0,0,1,.587.133,1.937,1.937,0,0,1,.555.4,2.714,2.714,0,0,1,.5.629,1.266,1.266,0,0,1,.173.612,1.926,1.926,0,0,1-.661,1.289.052.052,
+        0,0,1-.016.033l-.033.032-.048.049a4.42,4.42,0,0,0-.96-1.507,4.709,4.709,0,0,0-1.473-1.011Zm-9.692,13.375-1.794.6q.148-.5.546-1.73t.511-1.648a3.4,
+        3.4,0,0,1,1.521.926,3.151,3.151,0,0,1,.8,1.324q-.333.118-1.582.53Z"
+      />
+      <path
+        d="M938.7-176.431a.5.5,0,0,1-.359-.151l-2.276-2.355a.5.5,0,0,1-.14-.347v-3.425a.5.5,0,0,1,.5-.5h0a.5.5,0,0,1,.5.5h0v3.225l2.135
+        ,2.209a.5.5,0,0,1-.011.7h0A.49.49,0,0,1,938.7-176.431Z"
+      />
+      <path
+        d="M936.422-185.009a5.49,5.49,0,0,0-5.484,5.484,5.487,5.487,0,0,0,5.484,5.484,5.491,5.491,0,0,0,5.484-5.484A5.491,5.491,0,0,0,
+        936.422-185.009Zm0,9.97a4.487,4.487,0,0,1-4.487-4.487,4.486,4.486,0,0,1,4.487-4.486,4.486,4.486,0,0,1,4.487,4.486A4.487,
+        4.487,0,0,1,936.422-175.039Z"
+      />
+    </g>
+  </svg>
+);
+
+export default RecentlyCreatedIcon;

+ 35 - 0
src/client/js/components/Icons/ShareLinkIcon.jsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+const ShareLink = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+    <g transform="translate(-142 -502)">
+      <rect width="20" height="20" transform="translate(142 502)" fill="none" />
+      <g transform="translate(16 286.938)">
+        <path
+          d="M-1.813-3.563a2.711,2.711,0,0,0-1.274.308,2.8,2.8,0,0,0-.976.835L-11.48-6.2a2.676,2.676,
+          0,0,0,.105-.738,2.555,2.555,0,0,0-.044-.466,3.34,3.34,0,0,0-.114-.448l7.453-3.621a2.71,2.71,
+          0,0,0,.984.853,2.764,2.764,0,0,0,1.283.308,2.708,2.708,0,0,0,1.986-.826A2.708,2.708,0,0,
+          0,1-13.125a2.751,2.751,0,0,0-.378-1.406A2.793,2.793,0,0,0-.406-15.56a2.751,2.751,0,0,
+          0-1.406-.378,2.751,2.751,0,0,0-1.406.378,2.793,2.793,0,0,0-1.028,1.028,2.751,2.751,0,0,0-.378,
+          1.406v.105a.64.64,0,0,0,.009.105.641.641,0,0,1,.009.105A.641.641,0,0,0-4.6-12.7a.694.694,0,0,0,
+          .026.105.332.332,0,0,1,.018.105l-7.559,3.674a2.735,2.735,0,0,0-.923-.686,2.727,2.727,0,0,
+          0-1.151-.246,2.708,2.708,0,0,0-1.986.826A2.708,2.708,0,0,0-17-6.937a2.708,2.708,0,0,0,
+          .826,1.986,2.708,2.708,0,0,0,1.986.826A2.666,2.666,0,0,0-11.99-5.2l7.453,3.8a1.388,1.388,0,0,
+          0-.053.211q-.018.105-.026.22t-.009.22A2.751,2.751,0,0,0-4.247.656,2.792,2.792,0,0,0-3.219,
+          1.685a2.751,2.751,0,0,0,1.406.378A2.708,2.708,0,0,0,.174,1.236,2.708,2.708,0,0,0,1-.75,2.708,
+          2.708,0,0,0,.174-2.736,2.708,2.708,0,0,0-1.813-3.563Zm-1.2-10.758a1.627,1.627,0,0,1,1.2-.492,
+          1.627,1.627,0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2,1.627,1.627,0,0,1-.492,1.2,1.627,1.627,
+          0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,1.627,0,0,1-.492-1.2A1.627,1.627,0,0,
+          1-3.008-14.32Zm-9.984,8.578a1.627,1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,
+          1.627,0,0,1-.492-1.2,1.627,1.627,0,0,1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,
+          0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2A1.627,1.627,0,0,1-12.992-5.742ZM-.617.445a1.627,
+          1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492A1.627,1.627,0,0,1-3.5-.75a1.627,1.627,0,0,
+          1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,0,0,1,1.2.492A1.627,1.627,0,0,1-.125-.75,1.627,1.627,0,0,1-.617.445Z"
+          transform="translate(144 232)"
+        />
+      </g>
+    </g>
+  </svg>
+);
+
+export default ShareLink;

+ 19 - 0
src/client/js/components/Icons/TimeLineIcon.jsx

@@ -0,0 +1,19 @@
+import React from 'react';
+
+const TimeLine = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path
+      d="M13.6,4.6a1.2,1.2,0,0,1-1.2,1.2,1,1,0,0,1-.3,0L10,7.89a1.1,1.1,0,0,1,0,.31,1.2,1.2,0,1,1-2.4,0,1.1,1.1,0,0,1,
+      0-.31L6.11,6.36a1.3,1.3,0,0,1-.62,0L2.75,9.1a1,1,0,0,1,0,.3A1.2,1.2,0,1,1,1.6,8.2a1,1,0,0,1,.3,0L4.64,
+      5.51a1.1,1.1,0,0,1,0-.31A1.2,1.2,0,0,1,7,5.2a1.1,1.1,0,0,1,0,.31L8.49,7a1.3,1.3,0,0,1,.62,0L11.25,4.9a1,
+      1,0,0,1-.05-.3,1.2,1.2,0,1,1,2.4,0Z"
+    />
+  </svg>
+);
+
+export default TimeLine;

+ 39 - 28
src/client/js/components/LikeButton.jsx

@@ -1,53 +1,64 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { toastError } from '../util/apiNotification';
+import { UncontrolledTooltip } from 'reactstrap';
+import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
+
+import { toastError } from '../util/apiNotification';
 import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
 
 class LikeButton extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-      isLiked: props.isLiked,
-    };
-
     this.handleClick = this.handleClick.bind(this);
   }
 
   async handleClick() {
-    const { appContainer, pageId } = this.props;
-    const bool = !this.state.isLiked;
+    const { appContainer, pageContainer } = this.props;
+    const { isGuestUser } = appContainer;
+
+    if (isGuestUser) {
+      return;
+    }
+
     try {
-      await appContainer.apiv3.put('/page/likes', { pageId, bool });
-      this.setState({ isLiked: bool });
+      pageContainer.toggleLike();
     }
     catch (err) {
       toastError(err);
     }
   }
 
-  isUserLoggedIn() {
-    return this.props.appContainer.currentUserId != null;
-  }
 
   render() {
-    // if guest user
-    if (!this.isUserLoggedIn()) {
-      return <div></div>;
-    }
+    const { appContainer, pageContainer, t } = this.props;
+    const { isGuestUser } = appContainer;
 
     return (
-      <button
-        type="button"
-        onClick={this.handleClick}
-        className={`btn rounded-circle btn-like border-0 d-edit-none
-        ${this.state.isLiked ? 'active' : ''}`}
-      >
-        <i className="icon-like"></i>
-      </button>
+      <div>
+        <button
+          type="button"
+          id="like-button"
+          onClick={this.handleClick}
+          className={`btn btn-like border-0
+          ${pageContainer.state.isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+        >
+          <i className="icon-like mr-3"></i>
+          <span className="total-likes">
+            {pageContainer.state.sumOfLikers}
+          </span>
+        </button>
+
+        {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+        )}
+      </div>
     );
   }
 
@@ -56,14 +67,14 @@ class LikeButton extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const LikeButtonWrapper = withUnstatedContainers(LikeButton, [AppContainer]);
+const LikeButtonWrapper = withUnstatedContainers(LikeButton, [AppContainer, PageContainer]);
 
 LikeButton.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
-  pageId: PropTypes.string,
-  isLiked: PropTypes.bool,
+  t: PropTypes.func.isRequired,
   size: PropTypes.string,
 };
 
-export default LikeButtonWrapper;
+export default withTranslation()(LikeButtonWrapper);

+ 2 - 4
src/client/js/components/Me/ApiSettings.jsx

@@ -25,7 +25,7 @@ class ApiSettings extends React.Component {
       await appContainer.apiv3Put('/personal-setting/api-token');
 
       await personalContainer.retrievePersonalData();
-      toastSuccess(t('toaster.update_successed', { target: t('personal_settings.update_password') }));
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
     }
     catch (err) {
       toastError(err);
@@ -38,9 +38,7 @@ class ApiSettings extends React.Component {
     return (
       <React.Fragment>
 
-        <div className="container-fluid my-4">
-          <h2 className="border-bottom">{ t('API Token Settings') }</h2>
-        </div>
+        <h2 className="border-bottom my-4">{ t('API Token Settings') }</h2>
 
         <div className="row mb-3">
           <label htmlFor="apiToken" className="col-md-3 text-md-right">{t('Current API Token')}</label>

+ 7 - 9
src/client/js/components/Me/ExternalAccountLinkedMe.jsx

@@ -67,15 +67,13 @@ class ExternalAccountLinkedMe extends React.Component {
 
     return (
       <Fragment>
-        <div className="container-fluid my-4">
-          <h2 className="border-bottom">
-            <button type="button" className="btn btn-outline-secondary btn-sm pull-right" onClick={this.openAssociateModal}>
-              <i className="icon-plus" aria-hidden="true" />
-            Add
-            </button>
-            { t('admin:user_management.external_accounts') }
-          </h2>
-        </div>
+        <h2 className="border-bottom my-4">
+          <button type="button" className="btn btn-outline-secondary btn-sm pull-right" onClick={this.openAssociateModal}>
+            <i className="icon-plus" aria-hidden="true" />
+          Add
+          </button>
+          { t('admin:user_management.external_accounts') }
+        </h2>
 
         <table className="table table-bordered table-user-list">
           <thead>

+ 4 - 6
src/client/js/components/Me/PasswordSettings.jsx

@@ -55,7 +55,7 @@ class PasswordSettings extends React.Component {
       });
       this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
       await personalContainer.retrievePersonalData();
-      toastSuccess(t('toaster.update_successed', { target: t('personal_settings.update_password') }));
+      toastSuccess(t('toaster.update_successed', { target: t('Password') }));
     }
     catch (err) {
       toastError(err);
@@ -90,11 +90,9 @@ class PasswordSettings extends React.Component {
           <div className="alert alert-warning">{ t('personal_settings.password_is_not_set') }</div>
         ) }
 
-        <div className="container-fluid my-4">
-          {(this.state.isPasswordSet)
-            ? <h2 className="border-bottom">{t('personal_settings.update_password')}</h2>
-          : <h2 className="border-bottom">{t('personal_settings.set_new_password')}</h2>}
-        </div>
+        {(this.state.isPasswordSet)
+          ? <h2 className="border-bottom my-4">{t('personal_settings.update_password')}</h2>
+        : <h2 className="border-bottom my-4">{t('personal_settings.set_new_password')}</h2>}
         {(this.state.isPasswordSet)
         && (
           <div className="row mb-3">

+ 40 - 50
src/client/js/components/Me/PersonalSettings.jsx

@@ -1,63 +1,53 @@
 
-import React, { Fragment } from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import UserSettings from './UserSettings';
 import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import ApiSettings from './ApiSettings';
 
-class PersonalSettings extends React.Component {
+const PersonalSettings = (props) => {
+
+  const { t } = props;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      user_infomation: {
+        Icon: () => <i className="icon-fw icon-user"></i>,
+        Content: UserSettings,
+        i18n: t('User Information'),
+        index: 0,
+      },
+      external_accounts: {
+        Icon: () => <i className="icon-fw icon-share-alt"></i>,
+        Content: ExternalAccountLinkedMe,
+        i18n: t('admin:user_management.external_accounts'),
+        index: 1,
+      },
+      password_settings: {
+        Icon: () => <i className="icon-fw icon-lock"></i>,
+        Content: PasswordSettings,
+        i18n: t('Password Settings'),
+        index: 2,
+      },
+      api_settings: {
+        Icon: () => <i className="icon-fw icon-paper-plane"></i>,
+        Content: ApiSettings,
+        i18n: t('API Settings'),
+        index: 3,
+      },
+    };
+  }, [t]);
+
+
+  return (
+    <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+  );
 
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
-        <div className="personal-settings">
-          <ul className="nav nav-tabs" role="tablist">
-            <li className="nav-item">
-              <a className="nav-link active" href="#user-settings" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-user"></i>{ t('User Information') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#external-accounts" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-share-alt"></i>{ t('admin:user_management.external_accounts') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#password-settings" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-lock"></i>{ t('Password Settings') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#apiToken" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-paper-plane"></i>{ t('API Settings') }
-              </a>
-            </li>
-          </ul>
-          <div className="tab-content p-t-10">
-            <div id="user-settings" className="tab-pane active" role="tabpanel">
-              <UserSettings />
-            </div>
-            <div id="external-accounts" className="tab-pane" role="tabpanel">
-              <ExternalAccountLinkedMe />
-            </div>
-            <div id="password-settings" className="tab-pane" role="tabpanel">
-              <PasswordSettings />
-            </div>
-            <div id="apiToken" className="tab-pane" role="tabpanel">
-              <ApiSettings />
-            </div>
-          </div>
-        </div>
-      </Fragment>
-    );
-  }
-
-}
+};
 
 PersonalSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next

+ 2 - 3
src/client/js/components/Me/ProfileImageSettings.jsx

@@ -102,7 +102,7 @@ class ProfileImageSettings extends React.Component {
     return (
       <React.Fragment>
         <div className="row">
-          <div className="col-md-3 offset-1 col-sm-4">
+          <div className="col-md-6 col-12 mb-3 mb-md-0">
             <h4>
               <div className="custom-control custom-radio radio-primary">
                 <input
@@ -122,11 +122,10 @@ class ProfileImageSettings extends React.Component {
                 </a>
               </div>
             </h4>
-
             <img src={this.generateGravatarSrc()} width="64" />
           </div>
 
-          <div className="col-md-3 offset-1 col-sm-4">
+          <div className="col-md-6 col-12">
             <h4>
               <div className="custom-control custom-radio radio-primary">
                 <input

+ 6 - 8
src/client/js/components/Me/UserSettings.jsx

@@ -13,16 +13,14 @@ class UserSettings extends React.Component {
 
     return (
       <Fragment>
-        <div className="container-fluid my-4">
-          <h2 className="border-bottom">{t('Basic Info')}</h2>
+        <div className="mb-5">
+          <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
+          <BasicInfoSettings />
         </div>
-        <BasicInfoSettings />
-
-        <div className="container-fluid my-4">
-          <h2 className="border-bottom">{t('Set Profile Image')}</h2>
+        <div className="mb-5">
+          <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
+          <ProfileImageSettings />
         </div>
-        <ProfileImageSettings />
-
       </Fragment>
     );
   }

+ 7 - 6
src/client/js/components/MyDraftList/Draft.jsx

@@ -82,20 +82,21 @@ class Draft extends React.Component {
 
   renderAccordionTitle(isExist) {
     const { isPanelExpanded } = this.state;
-
-    const iconClass = isPanelExpanded ? 'caret-opened' : '';
+    const { t } = this.props;
+    const iconClass = isPanelExpanded ? 'fa-rotate-90' : '';
 
     return (
       <span>
-        <i className={`caret ${iconClass}`}></i>
-        <span className="mx-2" onClick={() => this.setState({ isPanelExpanded: !isPanelExpanded })}>
+
+        <span className="mr-2 draft-path" onClick={() => this.setState({ isPanelExpanded: !isPanelExpanded })}>
+          <i className={`fa fa-fw fa-angle-right mr-2 ${iconClass}`}></i>
           {this.props.path}
         </span>
         { isExist && (
-          <span>({this.props.t('page exists')})</span>
+          <span className="badge badge-warning">{t('page exists')}</span>
         ) }
         { !isExist && (
-          <span className="badge badge-secondary">draft</span>
+          <span className="badge badge-info">draft</span>
         ) }
 
         <a className="ml-2" href={this.props.path}><i className="icon icon-login"></i></a>

+ 7 - 7
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -67,9 +67,8 @@ class MyDraftList extends React.Component {
   }
 
   getCurrentDrafts(selectPageNumber) {
-    const { appContainer } = this.props;
 
-    const limit = appContainer.getConfig().recentCreatedLimit;
+    const limit = 50; // implement only this component.(this default value is 50 (pageLimitationL))
 
     const totalDrafts = this.state.drafts.length;
     const activePage = selectPageNumber;
@@ -135,15 +134,14 @@ class MyDraftList extends React.Component {
     const totalCount = this.state.totalDrafts;
 
     return (
-      <div className="page-list-container-create">
-
+      <div className="page-list-container-create ">
         { totalCount === 0
-          && <span>No drafts yet.</span>
+          && <span className="mt-2">No drafts yet.</span>
         }
 
         { totalCount > 0 && (
           <React.Fragment>
-            <div className="d-flex justify-content-between">
+            <div className="d-flex justify-content-between mt-2">
               <h4>Total: {totalCount} drafts</h4>
               <div className="align-self-center">
                 <button type="button" className="btn btn-sm btn-outline-danger" onClick={this.clearAllDrafts}>
@@ -153,7 +151,7 @@ class MyDraftList extends React.Component {
               </div>
             </div>
 
-            <div className="tab-pane mt-5 accordion" id="draft-list">
+            <div className="tab-pane mt-2 accordion" id="draft-list">
               {draftList}
             </div>
             <PaginationWrapper
@@ -161,6 +159,8 @@ class MyDraftList extends React.Component {
               changePage={this.handlePage}
               totalItemsCount={this.state.totalDrafts}
               pagingLimit={this.state.pagingLimit}
+              align="center"
+              size="sm"
             />
           </React.Fragment>
         ) }

+ 14 - 3
src/client/js/components/Navbar/AuthorInfo.jsx

@@ -6,22 +6,31 @@ import { userPageRoot } from '@commons/util/path-utils';
 import UserPicture from '../User/UserPicture';
 
 const AuthorInfo = (props) => {
-  const { mode, user, date } = props;
+  const {
+    mode, user, date, locate,
+  } = props;
 
-  const infoLabel = mode === 'create'
+  const infoLabelForSubNav = mode === 'create'
     ? 'Created by'
     : 'Updated by';
+  const infoLabelForFooter = mode === 'create'
+    ? 'Last revision posted at'
+    : 'Created at';
   const userLabel = user != null
     ? <a href={userPageRoot(user)}>{user.name}</a>
     : <i>Unknown</i>;
 
+  if (locate === 'footer') {
+    return <p>{infoLabelForFooter} {date} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
+  }
+
   return (
     <div className="d-flex align-items-center">
       <div className="mr-2">
         <UserPicture user={user} size="sm" />
       </div>
       <div>
-        <div>{infoLabel} {userLabel}</div>
+        <div>{infoLabelForSubNav} {userLabel}</div>
         <div className="text-muted text-date">{date}</div>
       </div>
     </div>
@@ -32,10 +41,12 @@ AuthorInfo.propTypes = {
   date: PropTypes.string.isRequired,
   user: PropTypes.object,
   mode: PropTypes.oneOf(['create', 'update']),
+  locate: PropTypes.oneOf(['subnav', 'footer']),
 };
 
 AuthorInfo.defaultProps = {
   mode: 'create',
+  locate: 'subnav',
 };
 
 

+ 1 - 1
src/client/js/components/Navbar/DrawerToggler.jsx

@@ -18,7 +18,7 @@ const DrawerToggler = (props) => {
 
   return (
     <button
-      className="grw-drawer-toggler btn btn-secondary btn-xl"
+      className="grw-drawer-toggler btn btn-secondary"
       type="button"
       aria-expanded="false"
       aria-label="Toggle navigation"

+ 13 - 4
src/client/js/components/Navbar/GrowiNavbar.jsx

@@ -3,11 +3,13 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { UncontrolledTooltip } from 'reactstrap';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '../../services/NavigationContainer';
 import AppContainer from '../../services/AppContainer';
 
-import GrowiLogo from '../GrowiLogo';
+
+import GrowiLogo from '../Icons/GrowiLogo';
 
 import PersonalDropdown from './PersonalDropdown';
 import GlobalSearch from './GlobalSearch';
@@ -45,10 +47,18 @@ class GrowiNavbar extends React.Component {
 
     return (
       <li className="nav-item confidential text-light">
-        <i className="icon-info d-md-none" data-toggle="tooltip" title={crowi.confidential} />
+        <i id="confidentialTooltip" className="icon-info d-md-none" />
         <span className="d-none d-md-inline">
           {crowi.confidential}
         </span>
+        <UncontrolledTooltip
+          placement="bottom"
+          trigger="click"
+          target="confidentialTooltip"
+          className="d-md-none"
+        >
+          {crowi.confidential}
+        </UncontrolledTooltip>
       </li>
     );
   }
@@ -76,10 +86,9 @@ class GrowiNavbar extends React.Component {
         {/* Navbar Right  */}
         <ul className="navbar-nav ml-auto">
           {this.renderNavbarRight()}
+          {crowi.confidential != null && this.renderConfidential()}
         </ul>
 
-        {crowi.confidential != null && this.renderConfidential()}
-
         { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
           <div className="grw-global-search grw-global-search-top position-absolute">
             <GlobalSearch />

+ 16 - 3
src/client/js/components/Navbar/GrowiNavbarBottom.jsx

@@ -33,17 +33,30 @@ const GrowiNavbarBottom = (props) => {
 
         <ul className="navbar-nav w-100">
           <li className="nav-item">
-            <a type="button" className="nav-link btn-lg" onClick={() => navigationContainer.toggleDrawer()}>
+            <a
+              role="button"
+              className="nav-link btn-lg"
+              onClick={() => navigationContainer.toggleDrawer()}
+            >
               <i className="icon-menu"></i>
             </a>
           </li>
           <li className="nav-item mx-auto">
-            <a type="button" className="nav-link btn-lg" data-target="#grw-global-search-collapse" data-toggle="collapse">
+            <a
+              role="button"
+              className="nav-link btn-lg"
+              data-target="#grw-global-search-collapse"
+              data-toggle="collapse"
+            >
               <i className="icon-magnifier"></i>
             </a>
           </li>
           <li className="nav-item">
-            <a type="button" className="nav-link btn-lg" onClick={() => navigationContainer.openPageCreateModal()}>
+            <a
+              role="button"
+              className="nav-link btn-lg"
+              onClick={() => navigationContainer.openPageCreateModal()}
+            >
               <i className="icon-pencil"></i>
             </a>
           </li>

+ 56 - 128
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -3,8 +3,6 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
-import { isTrashPage } from '@commons/util/path-utils';
-
 import DevidedPagePath from '@commons/models/devided-page-path';
 import LinkedPagePath from '@commons/models/linked-page-path';
 import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
@@ -14,30 +12,30 @@ import AppContainer from '../../services/AppContainer';
 import NavigationContainer from '../../services/NavigationContainer';
 import PageContainer from '../../services/PageContainer';
 
-import RevisionPathControls from '../Page/RevisionPathControls';
+import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
-import LikeButton from '../LikeButton';
-import BookmarkButton from '../BookmarkButton';
+import SubnavButtons from './SubNavButtons';
+import PageEditorModeManager from './PageEditorModeManager';
 
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
-import UserPicture from '../User/UserPicture';
-
 
-// eslint-disable-next-line react/prop-types
-const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
+const PagePathNav = ({
+  // eslint-disable-next-line react/prop-types
+  pageId, pagePath, isEditorMode, isCompactMode,
+}) => {
 
   const dPagePath = new DevidedPagePath(pagePath, false, true);
 
   let formerLink;
   let latterLink;
 
-  // when the path is root or first level
-  if (dPagePath.isRoot || dPagePath.isFormerRoot) {
+  // one line
+  if (dPagePath.isRoot || dPagePath.isFormerRoot || isEditorMode) {
     const linkedPagePath = new LinkedPagePath(pagePath);
     latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
   }
-  // when the path is second level or deeper
+  // two line
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -45,165 +43,95 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
     latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
   }
 
+  const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
+  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0';
+
   return (
     <div className="grw-page-path-nav">
       {formerLink}
-      <span className="d-flex align-items-center flex-wrap">
+      <span className="d-flex align-items-center">
         <h1 className="m-0">{latterLink}</h1>
-        <RevisionPathControls
-          pageId={pageId}
-          pagePath={pagePath}
-          isPageForbidden={isPageForbidden}
-        />
-      </span>
-    </div>
-  );
-};
-
-// eslint-disable-next-line react/prop-types
-const UserPagePathNav = ({ pageId, pagePath }) => {
-  const linkedPagePath = new LinkedPagePath(pagePath);
-  const latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-
-  return (
-    <div className="grw-page-path-nav">
-      <span className="d-flex align-items-center flex-wrap">
-        <h4 className="grw-user-page-path">{latterLink}</h4>
-        <RevisionPathControls
-          pageId={pageId}
-          pagePath={pagePath}
-        />
-      </span>
-    </div>
-  );
-};
-
-/* eslint-disable react/prop-types */
-const UserInfo = ({ pageUser }) => {
-  return (
-    <div className="grw-users-info d-flex align-items-center d-edit-none">
-      <UserPicture user={pageUser} />
-
-      <div className="users-meta">
-        <h1 className="user-page-name">
-          {pageUser.name}
-        </h1>
-        <div className="user-page-meta mt-1 mb-0">
-          <span className="user-page-username mr-2"><i className="icon-user mr-1"></i>{pageUser.username}</span>
-          <span className="user-page-email mr-2">
-            <i className="icon-envelope mr-1"></i>
-            {pageUser.isEmailPublished ? pageUser.email : '*****'}
-          </span>
-          {pageUser.introduction && <span className="user-page-introduction">{pageUser.introduction}</span>}
+        <div className="mx-2">
+          <CopyDropdown
+            pageId={pageId}
+            pagePath={pagePath}
+            dropdownToggleId={copyDropdownId}
+            dropdownToggleClassName={copyDropdownToggleClassName}
+          >
+            <i className="ti-clipboard"></i>
+          </CopyDropdown>
         </div>
-      </div>
-
-    </div>
-  );
-};
-/* eslint-enable react/prop-types */
-
-/* eslint-disable react/prop-types */
-const PageReactionButtons = ({ appContainer, pageContainer }) => {
-
-  const { pageId, isLiked, pageUser } = pageContainer.state;
-
-  return (
-    <>
-      {pageUser == null && (
-      <span className="mr-2">
-        <LikeButton pageId={pageId} isLiked={isLiked} />
       </span>
-      )}
-      <span className="mr-2">
-        <BookmarkButton pageId={pageId} crowi={appContainer} />
-      </span>
-    </>
+    </div>
   );
 };
-/* eslint-enable react/prop-types */
 
 const GrowiSubNavigation = (props) => {
   const {
     appContainer, navigationContainer, pageContainer, isCompactMode,
   } = props;
-  const { isDrawerMode } = navigationContainer.state;
+  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
-    pageId, path, createdAt, creator, updatedAt, revisionAuthor,
-    isForbidden: isPageForbidden, pageUser,
+    pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
 
-  const isPageNotFound = pageId == null;
-  const isUserPage = pageUser != null;
-  const isPageInTrash = isTrashPage(path);
+  const { isGuestUser } = appContainer;
+  const isEditorMode = editorMode !== 'view';
+  // Tags cannot be edited while the new page and editorMode is view
+  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
 
-  // Display only the RevisionPath
-  if (isPageNotFound || isPageForbidden) {
-    return (
-      <div className="grw-subnav d-flex align-items-center justify-content-between">
-        <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
-      </div>
-    );
+  function onPageEditorModeButtonClicked(viewType) {
+    navigationContainer.setEditorMode(viewType);
   }
 
   return (
-    <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
 
       {/* Left side */}
-      <div className="d-flex">
+      <div className="d-flex grw-subnav-left-side">
         { isDrawerMode && (
-          <div className="d-none d-md-flex align-items-center border-right mr-3 pr-3">
+          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
             <DrawerToggler />
           </div>
         ) }
 
-        <div>
-          { !isCompactMode && !isPageNotFound && !isPageForbidden && !isUserPage && (
-            <div className="mb-2">
-              <TagLabels />
+        <div className="grw-path-nav-container">
+          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
+            <div className="grw-taglabels-container">
+              <TagLabels editorMode={editorMode} />
             </div>
           ) }
-
-          { isUserPage
-            ? (
-              <>
-                <UserPagePathNav pageId={pageId} pagePath={path} />
-                <UserInfo pageUser={pageUser} />
-              </>
-            )
-            : (
-              <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
-            )
-          }
-
+          <PagePathNav pageId={pageId} pagePath={path} isEditorMode={isEditorMode} isCompactMode={isCompactMode} />
         </div>
       </div>
 
       {/* Right side */}
       <div className="d-flex">
 
-        <div className="d-flex flex-column align-items-end justify-content-center">
+        <div className="d-flex flex-column align-items-end">
           <div className="d-flex">
-            { !isPageInTrash && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
-            <div className="mt-2">
-              {/* TODO: impl View / Edit / HackMD button group */}
-              {/* <div className="btn-group" role="group" aria-label="Basic example">
-              <button type="button" className="btn btn-outline-primary">Left</button>
-              <button type="button" className="btn btn-outline-primary">Middle</button>
-              <button type="button" className="btn btn-outline-primary">Right</button>
-            </div> */}
-            </div>
+            <SubnavButtons isCompactMode={isCompactMode} />
+          </div>
+          <div className="mt-2">
+            {pageContainer.isAbleToShowPageEditorModeManager && (
+              <PageEditorModeManager
+                onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+                isBtnDisabled={isGuestUser}
+                editorMode={editorMode}
+                isDeviceSmallerThanMd={isDeviceSmallerThanMd}
+              />
+            )}
           </div>
         </div>
 
         {/* Page Authors */}
-        { (!isCompactMode && !isUserPage) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none">
+        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
+          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
             <li className="pb-1">
-              <AuthorInfo user={creator} date={createdAt} />
+              <AuthorInfo user={creator} date={createdAt} locate="subnav" />
             </li>
             <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" />
+              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="subnav" />
             </li>
           </ul>
         ) }

+ 110 - 0
src/client/js/components/Navbar/PageEditorModeManager.jsx

@@ -0,0 +1,110 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+/* eslint-disable react/prop-types */
+const PageEditorModeButtonWrapper = React.memo(({
+  editorMode, isBtnDisabled, onClick, targetMode, icon, label,
+}) => {
+  const classNames = [`btn btn-outline-primary ${targetMode}-button px-1`];
+  if (editorMode === targetMode) {
+    classNames.push('active');
+  }
+  if (isBtnDisabled) {
+    classNames.push('disabled');
+  }
+
+  return (
+    <button
+      type="button"
+      className={classNames.join(' ')}
+      onClick={() => { onClick(targetMode) }}
+    >
+      <span className="d-flex flex-column flex-md-row justify-content-center">
+        <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>
+        <span className="grw-page-editor-mode-manager-label">{label}</span>
+      </span>
+    </button>
+  );
+});
+/* eslint-enable react/prop-types */
+
+function PageEditorModeManager(props) {
+  const {
+    t, editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+  } = props;
+
+
+  const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
+    if (isBtnDisabled) {
+      return;
+    }
+    if (onPageEditorModeButtonClicked != null) {
+      onPageEditorModeButtonClicked(viewType);
+    }
+  }, [isBtnDisabled, onPageEditorModeButtonClicked]);
+
+  return (
+    <>
+      <div
+        className="btn-group grw-page-editor-mode-manager"
+        role="group"
+        aria-label="page-editor-mode-manager"
+        id="grw-page-editor-mode-manager"
+      >
+        {(!isDeviceSmallerThanMd || editorMode !== 'view') && (
+          <PageEditorModeButtonWrapper
+            editorMode={editorMode}
+            isBtnDisabled={isBtnDisabled}
+            onClick={pageEditorModeButtonClickedHandler}
+            targetMode="view"
+            icon={<i className="icon-control-play" />}
+            label={t('view')}
+          />
+        )}
+        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+          <PageEditorModeButtonWrapper
+            editorMode={editorMode}
+            isBtnDisabled={isBtnDisabled}
+            onClick={pageEditorModeButtonClickedHandler}
+            targetMode="edit"
+            icon={<i className="icon-note" />}
+            label={t('Edit')}
+          />
+        )}
+        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+          <PageEditorModeButtonWrapper
+            editorMode={editorMode}
+            isBtnDisabled={isBtnDisabled}
+            onClick={pageEditorModeButtonClickedHandler}
+            targetMode="hackmd"
+            icon={<i className="fa fa-file-text-o" />}
+            label={t('hackmd.hack_md')}
+          />
+        )}
+      </div>
+      {isBtnDisabled && (
+        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+    </>
+  );
+
+}
+
+PageEditorModeManager.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  onPageEditorModeButtonClicked: PropTypes.func,
+  isBtnDisabled: PropTypes.bool,
+  editorMode: PropTypes.string,
+  isDeviceSmallerThanMd: PropTypes.bool,
+};
+
+PageEditorModeManager.defaultProps = {
+  isBtnDisabled: false,
+  isDeviceSmallerThanMd: false,
+};
+
+export default withTranslation()(PageEditorModeManager);

+ 67 - 0
src/client/js/components/Navbar/SubNavButtons.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
+import PageContainer from '../../services/PageContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import BookmarkButton from '../BookmarkButton';
+import LikeButton from '../LikeButton';
+import PageManagement from '../Page/PageManagement';
+
+const SubnavButtons = (props) => {
+  const {
+    appContainer, navigationContainer, pageContainer, isCompactMode,
+  } = props;
+
+  /* eslint-enable react/prop-types */
+
+  /* eslint-disable react/prop-types */
+  const PageReactionButtons = ({ pageContainer }) => {
+
+    return (
+      <>
+        {pageContainer.isAbleToShowLikeButton && (
+          <span>
+            <LikeButton />
+          </span>
+        )}
+        <span>
+          <BookmarkButton />
+        </span>
+
+      </>
+    );
+  };
+  /* eslint-enable react/prop-types */
+
+  const { editorMode } = navigationContainer.state;
+  const isViewMode = editorMode === 'view';
+
+  return (
+    <>
+      {isViewMode && (
+      <>
+        { pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
+        { pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} /> }
+      </>
+      )}
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, NavigationContainer, PageContainer]);
+
+
+SubnavButtons.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
+};
+
+export default SubnavButtonsWrapper;

+ 42 - 0
src/client/js/components/NotFoundPage.jsx

@@ -0,0 +1,42 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
+import PageList from './PageList';
+import PageTimeline from './PageTimeline';
+
+const NotFoundPage = (props) => {
+  const { t } = props;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: PageList,
+        i18n: t('page_list'),
+        index: 0,
+      },
+      timeLine: {
+        Icon: TimeLineIcon,
+        Content: PageTimeline,
+        i18n: t('Timeline View'),
+        index: 1,
+      },
+    };
+  }, [t]);
+
+
+  return (
+    <div className="mt-5 d-edit-none">
+      <CustomNavAndContents navTabMapping={navTabMapping} />
+    </div>
+  );
+};
+
+NotFoundPage.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+};
+
+export default withTranslation()(NotFoundPage);

+ 2 - 2
src/client/js/components/Page.jsx

@@ -129,12 +129,12 @@ class Page extends React.Component {
 
   render() {
     const { appContainer, pageContainer } = this.props;
-    const isMobile = appContainer.isMobile;
+    const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
     const { markdown } = pageContainer.state;
 
     return (
-      <div className={isMobile ? 'page-mobile' : ''}>
+      <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
 
         { isLoggedIn && (

+ 148 - 155
src/client/js/components/Page/CopyDropdown.jsx

@@ -1,82 +1,70 @@
-import React from 'react';
+import React, {
+  useState, useMemo, useCallback,
+} from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
 import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Tooltip,
 } from 'reactstrap';
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 
-class CopyDropdown extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      dropdownOpen: false,
-      tooltipOpen: false,
-      isParamsAppended: true,
-    };
+function encodeSpaces(str) {
+  if (str == null) {
+    return null;
+  }
 
-    this.id = (Math.random() * 1000).toString();
+  // Encode SPACE and IDEOGRAPHIC SPACE
+  return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
+}
 
-    this.toggle = this.toggle.bind(this);
-    this.showToolTip = this.showToolTip.bind(this);
-    this.generatePagePathWithParams = this.generatePagePathWithParams.bind(this);
-    this.generatePagePathUrl = this.generatePagePathUrl.bind(this);
-    this.generatePermalink = this.generatePermalink.bind(this);
-    this.generateMarkdownLink = this.generateMarkdownLink.bind(this);
-  }
 
-  toggle() {
-    this.setState({ dropdownOpen: !this.state.dropdownOpen });
-  }
+/* eslint-disable react/prop-types */
+const DropdownItemContents = ({ title, contents }) => (
+  <>
+    <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
+    <div className="card well mb-1 p-2">{contents}</div>
+  </>
+);
+/* eslint-enable react/prop-types */
 
-  showToolTip() {
-    this.setState({ tooltipOpen: true });
-    setTimeout(() => {
-      this.setState({ tooltipOpen: false });
-    }, 1000);
-  }
 
-  get uriParams() {
-    const { isParamsAppended } = this.state;
+const CopyDropdown = (props) => {
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+  const [tooltipOpen, setTooltipOpen] = useState(false);
+  const [isParamsAppended, setParamsAppended] = useState(!props.isShareLinkMode);
 
-    if (!isParamsAppended) {
+  /*
+   * functions to construct labels and URLs
+   */
+  const getUriParams = useCallback(() => {
+    if (!isParamsAppended || !dropdownOpen) {
       return '';
     }
 
     const {
       search, hash,
     } = window.location;
-    return `${search}${hash}`;
-  }
 
-  encodeSpaces(str) {
-    if (str == null) {
-      return null;
-    }
-
-    // Encode SPACE and IDEOGRAPHIC SPACE
-    return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
-  }
+    return `${search}${hash}`;
+  }, [isParamsAppended, dropdownOpen]);
 
-  generatePagePathWithParams() {
-    const { pagePath } = this.props;
-    return decodeURI(`${pagePath}${this.uriParams}`);
-  }
+  const pagePathWithParams = useMemo(() => {
+    const { pagePath } = props;
+    return decodeURI(`${pagePath}${getUriParams()}`);
+  }, [props, getUriParams]);
 
-  generatePagePathUrl() {
+  const pagePathUrl = useMemo(() => {
     const { origin } = window.location;
-    return `${origin}${this.encodeSpaces(this.generatePagePathWithParams())}`;
-  }
+    return `${origin}${encodeSpaces(pagePathWithParams)}`;
+  }, [pagePathWithParams]);
 
-  generatePermalink() {
+  const permalink = useMemo(() => {
     const { origin } = window.location;
-    const { pageId, isShareLinkMode } = this.props;
+    const { pageId, isShareLinkMode } = props;
 
     if (pageId == null) {
       return null;
@@ -85,141 +73,146 @@ class CopyDropdown extends React.Component {
       return decodeURI(`${origin}/share/${pageId}`);
     }
 
-    return this.encodeSpaces(decodeURI(`${origin}/${pageId}${this.uriParams}`));
-  }
+    return encodeSpaces(decodeURI(`${origin}/${pageId}${getUriParams()}`));
+  }, [props, getUriParams]);
 
-  generateMarkdownLink() {
-    const { pagePath } = this.props;
+  const markdownLink = useMemo(() => {
+    const { pagePath } = props;
 
-    const label = decodeURI(`${pagePath}${this.uriParams}`);
-    const permalink = this.generatePermalink();
+    const label = decodeURI(`${pagePath}${getUriParams()}`);
+    // const permalink = generatePermalink();
 
     return `[${label}](${permalink})`;
-  }
+  }, [props, getUriParams, permalink]);
 
-  DropdownItemContents = ({ title, contents }) => (
-    <>
-      <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-      <div className="card well mb-1 p-2">{contents}</div>
-    </>
-  );
 
-  render() {
-    const {
-      t, pageId, isShareLinkMode,
-    } = this.props;
-    const { isParamsAppended } = this.state;
-
-    const pagePathWithParams = this.generatePagePathWithParams();
-    const pagePathUrl = this.generatePagePathUrl();
-    const permalink = this.generatePermalink();
-
-    const copyTarget = isShareLinkMode ? `copyShareLink${pageId}` : 'copyPagePathDropdown';
-    const dropdownToggleStyle = isShareLinkMode ? 'btn btn-secondary' : 'd-block text-muted bg-transparent btn-copy border-0';
-
-    const { id, DropdownItemContents } = this;
-
-    const customSwitchForParamsId = `customSwitchForParams_${id}`;
-
-    return (
-      <>
-        <UncontrolledDropdown id={copyTarget} className="grw-copy-dropdown">
-          <DropdownToggle
-            caret
-            className={dropdownToggleStyle}
-            style={this.props.buttonStyle}
-          >
-            { isShareLinkMode ? (
-              <>Copy Link</>
-            ) : (<i className="ti-clipboard"></i>)}
-          </DropdownToggle>
-
-          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
-
-            <div className="d-flex align-items-center justify-content-between">
-              <DropdownItem header className="px-3">
-                { t('copy_to_clipboard.Copy to clipboard') }
-              </DropdownItem>
+  /**
+   * control
+   */
+  const toggleDropdown = useCallback(() => {
+    setDropdownOpen(!dropdownOpen);
+  }, [dropdownOpen]);
+
+  const toggleAppendParams = useCallback(() => {
+    setParamsAppended(!isParamsAppended);
+  }, [isParamsAppended]);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, []);
+
+
+  /*
+   * render
+   */
+  const {
+    t, dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
+  } = props;
+
+  const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
+
+  return (
+    <>
+      <Dropdown className="grw-copy-dropdown" isOpen={dropdownOpen} toggle={toggleDropdown}>
+        <DropdownToggle
+          caret
+          className={dropdownToggleClassName}
+        >
+          <span id={dropdownToggleId}>{children}</span>
+        </DropdownToggle>
+
+        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
+
+          <div className="d-flex align-items-center justify-content-between">
+            <DropdownItem header className="px-3">
+              { t('copy_to_clipboard.Copy to clipboard') }
+            </DropdownItem>
+            { !isShareLinkMode && (
               <div className="px-3 custom-control custom-switch custom-switch-sm">
                 <input
                   type="checkbox"
                   id={customSwitchForParamsId}
                   className="custom-control-input"
                   checked={isParamsAppended}
-                  onChange={e => this.setState({ isParamsAppended: !isParamsAppended })}
+                  onChange={toggleAppendParams}
                 />
                 <label className="custom-control-label small" htmlFor={customSwitchForParamsId}>Append params</label>
               </div>
-            </div>
+            ) }
+          </div>
+
+          <DropdownItem divider className="my-0"></DropdownItem>
+
+          {/* Page path */}
+          <CopyToClipboard text={pagePathWithParams} onCopy={showToolTip}>
+            <DropdownItem className="px-3">
+              <DropdownItemContents title={t('copy_to_clipboard.Page path')} contents={pagePathWithParams} />
+            </DropdownItem>
+          </CopyToClipboard>
+
+          <DropdownItem divider className="my-0"></DropdownItem>
+
+          {/* Page path URL */}
+          <CopyToClipboard text={pagePathUrl} onCopy={showToolTip}>
+            <DropdownItem className="px-3">
+              <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl} />
+            </DropdownItem>
+          </CopyToClipboard>
+          <DropdownItem divider className="my-0"></DropdownItem>
+
+          {/* Permanent Link */}
+          { pageId && (
+            <CopyToClipboard text={permalink} onCopy={showToolTip}>
+              <DropdownItem className="px-3">
+                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={permalink} />
+              </DropdownItem>
+            </CopyToClipboard>
+          )}
 
-            <DropdownItem divider className="my-0"></DropdownItem>
+          <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Page path */}
-            <CopyToClipboard text={pagePathWithParams} onCopy={this.showToolTip}>
+          {/* Page path + Permanent Link */}
+          { pageId && (
+            <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={showToolTip}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page path')} contents={pagePathWithParams} />
+                <DropdownItemContents title={t('copy_to_clipboard.Page path and permanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
               </DropdownItem>
             </CopyToClipboard>
+          )}
 
-            <DropdownItem divider className="my-0"></DropdownItem>
+          <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Page path URL */}
-            <CopyToClipboard text={pagePathUrl} onCopy={this.showToolTip}>
-              <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl} />
+          {/* Markdown Link */}
+          { pageId && (
+            <CopyToClipboard text={markdownLink} onCopy={showToolTip}>
+              <DropdownItem className="px-3 text-wrap">
+                <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={markdownLink} isContentsWrap />
               </DropdownItem>
             </CopyToClipboard>
-            <DropdownItem divider className="my-0"></DropdownItem>
-
-            {/* Permanent Link */}
-            { pageId && (
-              <CopyToClipboard text={permalink} onCopy={this.showToolTip}>
-                <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={permalink} />
-                </DropdownItem>
-              </CopyToClipboard>
-            )}
-
-            <DropdownItem divider className="my-0"></DropdownItem>
-
-            {/* Page path + Permanent Link */}
-            { pageId && (
-              <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={this.showToolTip}>
-                <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Page path and permanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
-                </DropdownItem>
-              </CopyToClipboard>
-            )}
-
-            <DropdownItem divider className="my-0"></DropdownItem>
-
-            {/* Markdown Link */}
-            { pageId && (
-              <CopyToClipboard text={this.generateMarkdownLink()} onCopy={this.showToolTip}>
-                <DropdownItem className="px-3 text-wrap">
-                  <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={this.generateMarkdownLink()} isContentsWrap />
-                </DropdownItem>
-              </CopyToClipboard>
-            )}
-          </DropdownMenu>
-
-        </UncontrolledDropdown>
-
-        <Tooltip placement="bottom" isOpen={this.state.tooltipOpen} target={copyTarget} fade={false}>
-          copied!
-        </Tooltip>
-      </>
-    );
-  }
+          )}
+        </DropdownMenu>
 
-}
+      </Dropdown>
+
+      <Tooltip placement="bottom" isOpen={tooltipOpen} target={dropdownToggleId} fade={false}>
+        copied!
+      </Tooltip>
+    </>
+  );
+};
 
 CopyDropdown.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
+  children: PropTypes.node.isRequired,
+  dropdownToggleId: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
+
   pageId: PropTypes.string,
-  buttonStyle: PropTypes.object,
+  dropdownToggleClassName: PropTypes.string,
   isShareLinkMode: PropTypes.bool,
 };
 

+ 74 - 0
src/client/js/components/Page/DisplaySwitcher.jsx

@@ -0,0 +1,74 @@
+import React from 'react';
+import { TabContent, TabPane } from 'reactstrap';
+import propTypes from 'prop-types';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import NavigationContainer from '../../services/NavigationContainer';
+import PageContainer from '../../services/PageContainer';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import UserInfo from '../User/UserInfo';
+import TableOfContents from '../TableOfContents';
+import UserContentsLinks from '../UserContentsLinks';
+import PageAccessories from '../PageAccessories';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+
+
+const DisplaySwitcher = (props) => {
+  const {
+    navigationContainer, pageContainer,
+  } = props;
+  const { editorMode } = navigationContainer.state;
+  const { pageUser } = pageContainer.state;
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId="view">
+          <div className="d-flex flex-column flex-lg-row-reverse">
+
+            <div className="grw-side-contents-container">
+              <div className="grw-side-contents-sticky-container">
+                <div className="border-bottom pb-1">
+                  <PageAccessories />
+                </div>
+
+                <div className="d-none d-lg-block">
+                  <div id="revision-toc" className="revision-toc">
+                    <TableOfContents />
+                  </div>
+                  {pageUser && <UserContentsLinks />}
+                </div>
+              </div>
+            </div>
+
+            <div className="flex-grow-1 flex-basis-0 mw-0">
+              {pageUser && <UserInfo pageUser={pageUser} />}
+              <Page />
+            </div>
+
+          </div>
+        </TabPane>
+        <TabPane tabId="edit">
+          <div id="page-editor">
+            <Editor />
+          </div>
+        </TabPane>
+        <TabPane tabId="hackmd">
+          <div id="page-editor-with-hackmd">
+            <PageEditorByHackmd />
+          </div>
+        </TabPane>
+      </TabContent>
+      {editorMode !== 'view' && <EditorNavbarBottom /> }
+    </>
+  );
+};
+
+DisplaySwitcher.propTypes = {
+  navigationContainer: propTypes.instanceOf(NavigationContainer).isRequired,
+  pageContainer: propTypes.instanceOf(PageContainer).isRequired,
+};
+
+
+export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer, PageContainer]);

+ 67 - 0
src/client/js/components/Page/NotFoundAlert.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+
+const NotFoundAlert = (props) => {
+  const { t, isHidden, isGuestUserMode } = props;
+  function clickHandler(viewType) {
+
+    // check guest user,
+    // disabled of button cannot be used for using tooltip.
+    if (isGuestUserMode) {
+      return;
+    }
+
+    if (props.onPageCreateClicked === null) {
+      return;
+    }
+    props.onPageCreateClicked(viewType);
+  }
+
+  if (isHidden) {
+    return null;
+  }
+
+
+  return (
+    <div className="border border-info p-3">
+      <div
+        className="col-md-12 p-0"
+      >
+        <h2 className="text-info lead">
+          <i className="icon-info pr-2 font-weight-bold" aria-hidden="true"></i>
+          {t('not_found_page.page_not_exist_alert')}
+        </h2>
+        <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
+          <button
+            type="button"
+            className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
+            onClick={() => { clickHandler('edit') }}
+          >
+            <i className="icon-note icon-fw" />
+            {t('not_found_page.Create Page')}
+          </button>
+        </div>
+
+
+        {isGuestUserMode && (
+          <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
+            {t('Not available for guest')}
+          </UncontrolledTooltip>
+        )}
+      </div>
+    </div>
+  );
+};
+
+
+NotFoundAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  onPageCreateClicked: PropTypes.func,
+  isHidden: PropTypes.bool.isRequired,
+  isGuestUserMode: PropTypes.bool.isRequired,
+};
+
+export default withTranslation()(NotFoundAlert);

+ 92 - 9
src/client/js/components/Page/PageManagement.jsx

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
+import urljoin from 'url-join';
 
 import { isTopPage } from '@commons/util/path-utils';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -11,10 +12,14 @@ import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
+import PagePresentationModal from '../PagePresentationModal';
+import PresentationIcon from '../Icons/PresentationIcon';
 
 
 const PageManagement = (props) => {
-  const { t, appContainer, pageContainer } = props;
+  const {
+    t, appContainer, pageContainer, isCompactMode,
+  } = props;
   const { path, isDeletable, isAbleToDeleteCompletely } = pageContainer.state;
 
   const { currentUser } = appContainer;
@@ -24,6 +29,7 @@ const PageManagement = (props) => {
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
+  const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
 
   function openPageRenameModalHandler() {
     setIsPageRenameModalShown(true);
@@ -57,6 +63,62 @@ const PageManagement = (props) => {
     setIsPageDeleteModalShown(false);
   }
 
+  function openPagePresentationModalHandler() {
+    setIsPagePresentationModalShown(true);
+  }
+
+  function closePagePresentationModalHandler() {
+    setIsPagePresentationModalShown(false);
+  }
+
+
+  // TODO GW-2746 bulk export pages
+  // async function getArchivePageData() {
+  //   try {
+  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
+  //     setTotalPages(res.data.dummy);
+  //   }
+  //   catch (err) {
+  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
+  //   }
+  // }
+
+  async function exportPageHandler(format) {
+    const { pageId, revisionId } = pageContainer.state;
+    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+    url.searchParams.append('format', format);
+    url.searchParams.append('revisionId', revisionId);
+    window.location.href = url.href;
+  }
+
+  // TODO GW-2746 create api to bulk export pages
+  // function openArchiveModalHandler() {
+  //   setIsArchiveCreateModalShown(true);
+  //   getArchivePageData();
+  // }
+
+  // TODO GW-2746 create api to bulk export pages
+  // function closeArchiveCreateModalHandler() {
+  //   setIsArchiveCreateModalShown(false);
+  // }
+
+  function renderDropdownItemForTopPage() {
+    return (
+      <>
+        <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
+          <i className="icon-fw icon-docs"></i> { t('Duplicate') }
+        </button>
+        {/* TODO Presentation Mode is not function. So if it is really necessary, survey this cause and implement Presentation Mode in top page */}
+        {/* <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
+          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
+        </button> */}
+        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
+        </button>
+        <div className="dropdown-divider"></div>
+      </>
+    );
+  }
 
   function renderDropdownItemForNotTopPage() {
     return (
@@ -67,6 +129,16 @@ const PageManagement = (props) => {
         <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
         </button>
+        <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
+          <i className="icon-fw"><PresentationIcon /></i> { t('Presentation Mode') }
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => { exportPageHandler('md') }}>
+          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
+        </button>
+        {/* TODO GW-2746 create api to bulk export pages */}
+        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
+          <i className="icon-fw"></i>{t('Create Archive Page')}
+        </button> */}
         <div className="dropdown-divider"></div>
       </>
     );
@@ -76,8 +148,8 @@ const PageManagement = (props) => {
     return (
       <>
         <div className="dropdown-divider"></div>
-        <button className="dropdown-item" type="button" onClick={openPageDeleteModalHandler}>
-          <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+        <button className="dropdown-item text-danger" type="button" onClick={openPageDeleteModalHandler}>
+          <i className="icon-fw icon-fire"></i> { t('Delete') }
         </button>
       </>
     );
@@ -109,6 +181,11 @@ const PageManagement = (props) => {
           path={path}
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}
         />
+        <PagePresentationModal
+          isOpen={isPagePresentationModalShown}
+          onClose={closePagePresentationModalHandler}
+          href="?presentation=1"
+        />
       </>
     );
   }
@@ -118,10 +195,10 @@ const PageManagement = (props) => {
       <>
         <button
           type="button"
-          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
+          className={`btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management ${isCompactMode && 'py-0'}`}
           data-toggle="dropdown"
         >
-          <i className="icon-options-vertical"></i>
+          <i className="icon-options"></i>
         </button>
       </>
     );
@@ -132,12 +209,12 @@ const PageManagement = (props) => {
       <>
         <button
           type="button"
-          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
+          className={`btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled ${isCompactMode && 'py-0'}`}
           id="icon-options-guest-tltips"
         >
-          <i className="icon-options-vertical"></i>
+          <i className="icon-options"></i>
         </button>
-        <UncontrolledTooltip placement="top" target="icon-options-guest-tltips">
+        <UncontrolledTooltip placement="top" target="icon-options-guest-tltips" fade={false}>
           {t('Not available for guest')}
         </UncontrolledTooltip>
       </>
@@ -149,7 +226,7 @@ const PageManagement = (props) => {
     <>
       {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
-        {!isTopPagePath && renderDropdownItemForNotTopPage()}
+        {isTopPagePath ? renderDropdownItemForTopPage() : renderDropdownItemForNotTopPage()}
         <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>
           <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
         </button>
@@ -170,6 +247,12 @@ PageManagement.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
+};
+
+PageManagement.defaultProps = {
+  isCompactMode: false,
 };
 
 export default withTranslation()(PageManagementWrapper);

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