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

Merge branch 'master' into feat/duplicate-with-subordinate-page

# Conflicts:
#	src/server/routes/apiv3/pages.js
takeru0001 5 лет назад
Родитель
Сommit
656ac72508
100 измененных файлов с 2296 добавлено и 1110 удалено
  1. 32 25
      .github/workflows/ci.yml
  2. 1 1
      .github/workflows/release-rc.yml
  3. 7 7
      .github/workflows/release.yml
  4. 7 0
      CHANGES.md
  5. 1 0
      config/logger/config.dev.js
  6. 3 2
      package.json
  7. 6 0
      public/images/icons/slack/slack-logo-dark-off.svg
  8. 6 0
      public/images/icons/slack/slack-logo-dark-on.svg
  9. 6 0
      public/images/icons/slack/slack-logo-off.svg
  10. 6 0
      public/images/icons/slack/slack-logo-on.svg
  11. 28 5
      resource/locales/en_US/admin/admin.json
  12. 16 2
      resource/locales/en_US/translation.json
  13. 26 5
      resource/locales/ja_JP/admin/admin.json
  14. 15 1
      resource/locales/ja_JP/translation.json
  15. 26 7
      resource/locales/zh_CN/admin/admin.json
  16. 19 4
      resource/locales/zh_CN/translation.json
  17. 34 23
      src/client/js/app.jsx
  18. 0 2
      src/client/js/base.jsx
  19. 1 1
      src/client/js/components/Admin/App/AppSetting.jsx
  20. 3 3
      src/client/js/components/Admin/App/AppSettingsPageContents.jsx
  21. 76 114
      src/client/js/components/Admin/App/AwsSetting.jsx
  22. 99 0
      src/client/js/components/Admin/App/FileUploadSetting.jsx
  23. 117 0
      src/client/js/components/Admin/App/GcsSettings.jsx
  24. 3 3
      src/client/js/components/Admin/App/MailSetting.jsx
  25. 30 38
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  26. 58 0
      src/client/js/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx
  27. 1 0
      src/client/js/components/Admin/ManageExternalAccount.jsx
  28. 1 1
      src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx
  29. 1 1
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  30. 1 0
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  31. 1 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  32. 1 0
      src/client/js/components/Admin/UserManagement.jsx
  33. 16 32
      src/client/js/components/BookmarkButton.jsx
  34. 156 0
      src/client/js/components/CustomNavigation.jsx
  35. 33 0
      src/client/js/components/ExpandOrContractButton.jsx
  36. 0 2
      src/client/js/components/Hotkeys/Subscribers/EditPage.jsx
  37. 28 0
      src/client/js/components/Icons/BookmarkIcon.jsx
  38. 0 0
      src/client/js/components/Icons/HistoryIcon.jsx
  39. 1 1
      src/client/js/components/Icons/PresentationIcon.jsx
  40. 44 0
      src/client/js/components/Icons/RecentlyCreatedIcon.jsx
  41. 12 14
      src/client/js/components/LikeButton.jsx
  42. 7 6
      src/client/js/components/MyDraftList/Draft.jsx
  43. 10 7
      src/client/js/components/MyDraftList/MyDraftList.jsx
  44. 40 81
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  45. 49 15
      src/client/js/components/Navbar/ThreeStrandedButton.jsx
  46. 39 0
      src/client/js/components/NotFoundPage.jsx
  47. 2 2
      src/client/js/components/Page.jsx
  48. 43 0
      src/client/js/components/Page/DisplaySwitcher.jsx
  49. 45 0
      src/client/js/components/Page/NotFoundAlert.jsx
  50. 35 9
      src/client/js/components/Page/PageManagement.jsx
  51. 1 9
      src/client/js/components/Page/RevisionPathControls.jsx
  52. 5 5
      src/client/js/components/Page/TagEditModal.jsx
  53. 18 21
      src/client/js/components/Page/TagLabels.jsx
  54. 41 0
      src/client/js/components/PageAccessories.jsx
  55. 83 101
      src/client/js/components/PageAccessoriesModal.jsx
  56. 89 0
      src/client/js/components/PageAccessoriesModalControl.jsx
  57. 6 11
      src/client/js/components/PageAttachment.jsx
  58. 17 16
      src/client/js/components/PageComment/Comment.jsx
  59. 2 0
      src/client/js/components/PageComment/CommentEditor.jsx
  60. 1 1
      src/client/js/components/PageComments.jsx
  61. 59 2
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  62. 7 18
      src/client/js/components/PageEditor/HandsontableModal.jsx
  63. 0 48
      src/client/js/components/PageEditor/PagePathNavForEditor.jsx
  64. 20 2
      src/client/js/components/PageEditorByHackmd.jsx
  65. 11 10
      src/client/js/components/PageHistory.jsx
  66. 1 1
      src/client/js/components/PageHistory/RevisionDiff.jsx
  67. 30 14
      src/client/js/components/PageList.jsx
  68. 97 0
      src/client/js/components/PageList/BookmarkList.jsx
  69. 15 9
      src/client/js/components/PageTimeline.jsx
  70. 7 10
      src/client/js/components/PaginationWrapper.jsx
  71. 11 12
      src/client/js/components/RecentCreated/RecentCreated.jsx
  72. 1 24
      src/client/js/components/SavePageControls.jsx
  73. 1 1
      src/client/js/components/SearchPage/SearchResult.jsx
  74. 1 1
      src/client/js/components/Sidebar/SidebarNav.jsx
  75. 21 0
      src/client/js/components/SlackLogo.jsx
  76. 17 15
      src/client/js/components/SlackNotification.jsx
  77. 30 20
      src/client/js/components/TableOfContents.jsx
  78. 1 0
      src/client/js/components/TagsList.jsx
  79. 0 95
      src/client/js/components/TopOfTableContents.jsx
  80. 32 0
      src/client/js/components/TrashPageList.jsx
  81. 5 5
      src/client/js/components/User/SeenUserInfo.jsx
  82. 41 0
      src/client/js/components/User/UserInfo.jsx
  83. 54 0
      src/client/js/components/UserContentsLinks.jsx
  84. 12 102
      src/client/js/legacy/crowi.js
  85. 105 42
      src/client/js/services/AdminAppContainer.js
  86. 47 7
      src/client/js/services/AdminCustomizeContainer.js
  87. 6 0
      src/client/js/services/AdminSocketIoContainer.js
  88. 33 2
      src/client/js/services/NavigationContainer.js
  89. 50 19
      src/client/js/services/PageContainer.js
  90. 6 4
      src/client/js/services/PageHistoryContainer.js
  91. 11 0
      src/client/js/services/SocketIoContainer.js
  92. 2 1
      src/client/styles/scss/_admin.scss
  93. 4 0
      src/client/styles/scss/_attachments.scss
  94. 5 0
      src/client/styles/scss/_comment.scss
  95. 1 11
      src/client/styles/scss/_comment_growi.scss
  96. 3 9
      src/client/styles/scss/_draft.scss
  97. 1 4
      src/client/styles/scss/_handsontable.scss
  98. 13 0
      src/client/styles/scss/_layout.scss
  99. 0 37
      src/client/styles/scss/_layout_growi.scss
  100. 80 7
      src/client/styles/scss/_mixins.scss

+ 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

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

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

@@ -72,7 +72,7 @@ jobs:
         echo ::set-env name=SUFFIX::$suffix
 
     - 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: |
@@ -105,12 +105,12 @@ jobs:
         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
+      uses: peter-evans/dockerhub-description@v2
+      with:
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        repository: weseek/growi
+        readme-filepath: ./docker/README.md
 
     - name: Check whether workspace is clean
       run: |

+ 7 - 0
CHANGES.md

@@ -11,6 +11,13 @@
 
 * Improvement: Basic layout of page
 * Support: Support MongoDB 4.0, 4.2 and 4.4
+* Support: Upgrade libs
+    * migrate-mongo
+    * mongoose
+
+## v4.1.9
+
+* Feature: Environment variables to set max connection size to deliver push messages to all clients
 
 ## v4.1.8
 

+ 1 - 0
config/logger/config.dev.js

@@ -17,6 +17,7 @@ module.exports = {
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
+  // 'growi:service:socket-io': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   // 'growi:service:mail': 'debug',
   'growi:lib:search': 'debug',

+ 3 - 2
package.json

@@ -73,6 +73,7 @@
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
     "@google-cloud/storage": "^3.3.0",
+    "@kobalab/socket.io-session": "^1.0.3",
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",
@@ -113,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",

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

+ 28 - 5
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",
@@ -107,8 +117,21 @@
       "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",
+
+      "list_num_s": "Number of list displayed on modals",
+      "list_num_desc_s": "Set number of list per page such as 'Pagelist', 'Timeline', 'Page History' and 'Share Link' 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",

+ 16 - 2
resource/locales/en_US/translation.json

@@ -50,6 +50,7 @@
   "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",
@@ -159,6 +160,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",
@@ -297,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.",
@@ -448,6 +459,7 @@
   "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.",
@@ -461,7 +473,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",
@@ -540,7 +554,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": {

+ 26 - 5
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設定",
@@ -107,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": "モーダルにおける <Pagelist> <Timeline> <Page History> <Share Link>での、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": "返信コメントを全て表示する",

+ 15 - 1
resource/locales/ja_JP/translation.json

@@ -51,6 +51,7 @@
   "attachment_data": "添付データ",
   "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "プレゼンテーション",
+  "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
@@ -162,6 +163,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": "最初のアカウントの作成",
@@ -299,6 +307,9 @@
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
     }
   },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "投稿時のページを表示する"
+  },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
@@ -450,6 +461,7 @@
   "hackmd":{
     "hack_md": "HackMD",
     "not_set_up": "HackMD はセットアップされていません",
+    "used_for_not_found": "HackMD は新しいページの作成には利用できません",
     "start_to_edit": "HackMD を開始する",
     "clone_page_content": "ページを複製して編集を開始します",
     "unsaved_draft": "HackMD のドラフトが保存されていません",
@@ -463,7 +475,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 通知",

+ 26 - 7
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": "您可以更改换行设置。",
@@ -118,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 'Pagelist', 'Timeline', 'Page History' and 'Share Link' 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": "显示所有回复评论",

+ 19 - 4
resource/locales/zh_CN/translation.json

@@ -52,6 +52,7 @@
   "attachment_data": "Attachment Data",
   "No_attachments_yet": "暂无附件",
 	"Presentation Mode": "演示文稿",
+  "The end": "结束",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",
@@ -77,6 +78,7 @@
 	"Shrink versions that have no diffs": "收缩没有差异的版本",
 	"User ID": "用户ID",
 	"Home": "首页",
+	"My Drafts": "My Drafts",
 	"User Settings": "用户设置",
 	"User Information": "用户信息",
 	"Basic Info": "基础信息",
@@ -158,7 +160,14 @@
 		"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": "创建初始用户",
@@ -277,6 +286,9 @@
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
 	},
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+  },
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "新建页面已存在",
@@ -423,7 +435,8 @@
 	},
 	"hackmd": {
     "hack_md": "HackMD",
-		"not_set_up": "HackMD is not set up.",
+    "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.",
@@ -437,7 +450,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",
@@ -527,7 +542,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": {

+ 34 - 23
src/client/js/app.jsx

@@ -8,34 +8,40 @@ 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 PageComments from './components/PageComments';
 import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
+import ShareLinkAlert from './components/Page/ShareLinkAlert';
+import TrashPageList from './components/TrashPageList';
 import TrashPageAlert from './components/Page/TrashPageAlert';
+import NotFoundPage from './components/NotFoundPage';
+import NotFoundAlert from './components/Page/NotFoundAlert';
 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 PageAccessories from './components/PageAccessories';
+import UserInfo from './components/User/UserInfo';
 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';
@@ -76,11 +82,24 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
+  'trash-page-list': <TrashPageList />,
+
+  'not-found-page': <NotFoundPage />,
+
+  'not-found-alert': <NotFoundAlert
+    onPageCreateClicked={navigationContainer.setEditorMode}
+    isHidden={pageContainer.state.isForbidden || pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
+  />,
+
   'page-timeline': <PageTimeline />,
 
   'personal-setting': <PersonalSettings crowi={personalContainer} />,
 
+  'my-drafts': <MyDraftList />,
+
   'grw-fab-container': <Fab />,
+
+  'share-link-alert': <ShareLinkAlert />,
 });
 
 // additional definitions if data exists
@@ -89,16 +108,19 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
+    'page-accessories': <PageAccessories />,
     'revision-toc': <TableOfContents />,
-    'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
 
-    '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) {
@@ -107,21 +129,10 @@ if (pageContainer.state.path != null) {
     'page': <Page />,
     'grw-subnav-container': <GrowiSubNavigation />,
     'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'user-info': <UserInfo pageUser={pageContainer.state.pageUser} />,
+    '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);

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

+ 30 - 38
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;
 
@@ -74,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
@@ -90,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">

+ 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 - 0
src/client/js/components/Admin/ManageExternalAccount.jsx

@@ -43,6 +43,7 @@ class ManageExternalAccount extends React.Component {
         totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
         pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
         align="right"
+        size="sm"
       />
 
     );

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

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

@@ -25,6 +25,7 @@ const Pager = (props) => {
       totalItemsCount={props.totalLinks}
       pagingLimit={props.limit}
       align="right"
+      size="sm"
     />
   );
 };
@@ -111,7 +112,6 @@ class ShareLinkSetting extends React.Component {
       shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
     } = adminGeneralSecurityContainer.state;
 
-
     return (
       <Fragment>
         <div className="mb-3">

+ 1 - 0
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -157,6 +157,7 @@ class UserGroupPage extends React.Component {
           changePage={this.handlePage}
           totalItemsCount={this.state.totalUserGroups}
           pagingLimit={this.state.pagingLimit}
+          size="sm"
         />
         <UserGroupDeleteModal
           userGroups={this.state.userGroups}

+ 1 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -64,6 +64,7 @@ class UserGroupPageList extends React.Component {
           changePage={this.handlePageChange}
           totalItemsCount={this.state.total}
           pagingLimit={this.state.pagingLimit}
+          size="sm"
         />
       </Fragment>
     );

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

@@ -121,6 +121,7 @@ class UserManagement extends React.Component {
           totalItemsCount={adminUsersContainer.state.totalUsers}
           pagingLimit={adminUsersContainer.state.pagingLimit}
           align="right"
+          size="sm"
         />
       </div>
     );

+ 16 - 32
src/client/js/components/BookmarkButton.jsx

@@ -2,46 +2,22 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { toastError } from '../util/apiNotification';
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageContainer from '../services/PageContainer';
 
 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;
-    }
-
-    try {
-      const response = await crowi.apiv3.get('/bookmarks', { pageId });
-      if (response.data.bookmark != null) {
-        this.setState({ isBookmarked: true });
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
   async handleClick() {
-    const { crowi, pageId } = this.props;
-    const bool = !this.state.isBookmarked;
+    const { pageContainer } = this.props;
 
     try {
-      await crowi.apiv3.put('/bookmarks', { pageId, bool });
-      this.setState({ isBookmarked: bool });
+      pageContainer.toggleBookmark();
     }
     catch (err) {
       toastError(err);
@@ -53,6 +29,7 @@ class BookmarkButton extends React.Component {
   }
 
   render() {
+    const { pageContainer } = this.props;
     // if guest user
     if (!this.isUserLoggedIn()) {
       return <div></div>;
@@ -64,18 +41,25 @@ class BookmarkButton extends React.Component {
         href="#"
         title="Bookmark"
         onClick={this.handleClick}
-        className={`btn rounded-circle btn-bookmark border-0 d-edit-none
+        className={`btn btn-bookmark border-0
           ${`btn-${this.props.size}`}
-          ${this.state.isBookmarked ? 'active' : ''}`}
+          ${pageContainer.state.isBookmarked ? 'active' : ''}`}
       >
-        <i className="icon-star"></i>
+        <i className="icon-star mr-3"></i>
+        <span className="total-bookmarks">
+          {pageContainer.state.sumOfBookmarks}
+        </span>
       </button>
     );
   }
 
 }
 
+const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [PageContainer]);
+
 BookmarkButton.propTypes = {
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   pageId: PropTypes.string,
   crowi: PropTypes.object.isRequired,
   size: PropTypes.string,
@@ -85,4 +69,4 @@ BookmarkButton.defaultProps = {
   size: 'md',
 };
 
-export default BookmarkButton;
+export default BookmarkButtonWrapper;

+ 156 - 0
src/client/js/components/CustomNavigation.jsx

@@ -0,0 +1,156 @@
+import React, {
+  useEffect, useState, useRef, useMemo, useCallback,
+} from 'react';
+import PropTypes from 'prop-types';
+import {
+  Nav, NavItem, NavLink, TabContent, TabPane,
+} from 'reactstrap';
+
+
+export const CustomNav = (props) => {
+  const navContainer = useRef();
+  const [sliderWidth, setSliderWidth] = useState(0);
+  const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
+
+  const { activeTab, navTabMapping, onNavSelected } = 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]);
+
+  return (
+    <div className="grw-custom-nav">
+      <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 grw-custom-navtab ${isActive && 'active'}`}
+              >
+                <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}%` }} />
+    </div>
+  );
+
+};
+
+CustomNav.propTypes = {
+  activeTab: PropTypes.string.isRequired,
+  navTabMapping: PropTypes.object.isRequired,
+  onNavSelected: PropTypes.func,
+};
+
+
+export 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: [],
+};
+
+
+const CustomNavigation = (props) => {
+  const { navTabMapping, defaultTabIndex, tabContentClasses } = props;
+  const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
+
+  return (
+    <React.Fragment>
+
+      <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={setActiveTab} />
+      <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
+
+    </React.Fragment>
+  );
+};
+
+CustomNavigation.propTypes = {
+  navTabMapping: PropTypes.object.isRequired,
+  defaultTabIndex: PropTypes.number,
+  tabContentClasses: PropTypes.arrayOf(PropTypes.string),
+};
+CustomNavigation.defaultProps = {
+  tabContentClasses: ['p-4'],
+};
+
+export default CustomNavigation;

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

+ 0 - 2
src/client/js/components/Hotkeys/Subscribers/EditPage.jsx

@@ -9,8 +9,6 @@ const EditPage = (props) => {
     if (document.getElementsByClassName('modal in').length > 0) {
       return;
     }
-    // show editor
-    $('a[data-toggle="tab"][href="#edit"]').tab('show');
 
     // remove this
     props.onDeleteRender(this);

+ 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/Icons/RecentChangesIcon.jsx → src/client/js/components/Icons/HistoryIcon.jsx


+ 1 - 1
src/client/js/components/Icons/PresentationIcon.jsx

@@ -3,7 +3,7 @@ import React from 'react';
 const PresentationIcon = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
-    width="12.25"
+    width="14"
     height="14"
     viewBox="0 0 12.25 14"
   >

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

+ 12 - 14
src/client/js/components/LikeButton.jsx

@@ -4,25 +4,20 @@ import PropTypes from 'prop-types';
 import { toastError } from '../util/apiNotification';
 import { withUnstatedContainers } from './UnstatedUtils';
 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 { pageContainer } = this.props;
     try {
-      await appContainer.apiv3.put('/page/likes', { pageId, bool });
-      this.setState({ isLiked: bool });
+      pageContainer.toggleLike();
     }
     catch (err) {
       toastError(err);
@@ -34,6 +29,7 @@ class LikeButton extends React.Component {
   }
 
   render() {
+    const { pageContainer } = this.props;
     // if guest user
     if (!this.isUserLoggedIn()) {
       return <div></div>;
@@ -43,10 +39,13 @@ class LikeButton extends React.Component {
       <button
         type="button"
         onClick={this.handleClick}
-        className={`btn rounded-circle btn-like border-0 d-edit-none
-        ${this.state.isLiked ? 'active' : ''}`}
+        className={`btn btn-like border-0 d-edit-none
+        ${pageContainer.state.isLiked ? 'active' : ''}`}
       >
-        <i className="icon-like"></i>
+        <i className="icon-like mr-3"></i>
+        <span className="total-likes">
+          {pageContainer.state.sumOfLikers}
+        </span>
       </button>
     );
   }
@@ -56,13 +55,12 @@ 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,
   size: PropTypes.string,
 };
 

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

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

@@ -22,6 +22,7 @@ class MyDraftList extends React.Component {
       currentDrafts: [],
       activePage: 1,
       totalDrafts: 0,
+      // [TODO: rename pageLimitationM to pageLimitationL]
       pagingLimit: Infinity,
     };
 
@@ -67,9 +68,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;
@@ -134,15 +134,16 @@ class MyDraftList extends React.Component {
     const totalCount = this.state.totalDrafts;
 
     return (
-      <div className="page-list-container-create">
-
+      <div className="page-list-container-create ">
+        <h1>My Drafts</h1>
+        <hr />
         { 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}>
@@ -152,7 +153,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
@@ -160,6 +161,8 @@ class MyDraftList extends React.Component {
               changePage={this.handlePage}
               totalItemsCount={this.state.totalDrafts}
               pagingLimit={this.state.pagingLimit}
+              align="center"
+              size="sm"
             />
           </React.Fragment>
         ) }

+ 40 - 81
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -22,7 +22,8 @@ import ThreeStrandedButton from './ThreeStrandedButton';
 
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
-import UserPicture from '../User/UserPicture';
+
+import PageManagement from '../Page/PageManagement';
 
 
 // eslint-disable-next-line react/prop-types
@@ -49,65 +50,29 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
   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}
-        />
+        <div className="mx-2">
+          <RevisionPathControls
+            pageId={pageId}
+            pagePath={pagePath}
+            isPageForbidden={isPageForbidden}
+          />
+        </div>
       </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>
-      </div>
-
-    </div>
-  );
-};
 /* eslint-enable react/prop-types */
 
 /* eslint-disable react/prop-types */
 const PageReactionButtons = ({ appContainer, pageContainer }) => {
 
-  const { pageId, isLiked, pageUser } = pageContainer.state;
+  const {
+    pageId, isLiked, pageUser,
+  } = pageContainer.state;
 
   return (
     <>
@@ -116,7 +81,7 @@ const PageReactionButtons = ({ appContainer, pageContainer }) => {
         <LikeButton pageId={pageId} isLiked={isLiked} />
       </span>
       )}
-      <span className="mr-2">
+      <span>
         <BookmarkButton pageId={pageId} crowi={appContainer} />
       </span>
     </>
@@ -128,73 +93,67 @@ const GrowiSubNavigation = (props) => {
   const {
     appContainer, navigationContainer, pageContainer, isCompactMode,
   } = props;
-  const { isDrawerMode } = navigationContainer.state;
+  const { isDrawerMode, editorMode } = navigationContainer.state;
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor,
-    isForbidden: isPageForbidden, pageUser,
+    isForbidden: isPageForbidden, pageUser, isNotCreatable, shareLinkId,
   } = pageContainer.state;
 
+  const { currentUser } = appContainer;
   const isPageNotFound = pageId == null;
+  // Tags cannot be edited while the new page and editorMode is view
+  const isTagLabelHidden = (editorMode !== 'edit' && isPageNotFound);
   const isUserPage = pageUser != null;
   const isPageInTrash = isTrashPage(path);
+  const isSharedPage = shareLinkId != null;
 
-  // 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 onThreeStrandedButtonClicked(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">
             <DrawerToggler />
           </div>
         ) }
 
-        <div>
-          { !isCompactMode && !isPageNotFound && !isPageForbidden && !isUserPage && (
+        <div className="grw-path-nav-container">
+          { !isCompactMode && !isTagLabelHidden && !isPageForbidden && !isUserPage && !isSharedPage && (
             <div className="mb-2">
-              <TagLabels />
+              <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} isPageForbidden={isPageForbidden} />
         </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} /> }
+            { !isPageInTrash && !isPageNotFound && !isPageForbidden && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
+            { !isPageNotFound && !isPageForbidden && <PageManagement isCompactMode={isCompactMode} /> }
           </div>
           <div className="mt-2">
-            <ThreeStrandedButton />
+            {!isNotCreatable && !isPageInTrash && !isPageForbidden && (
+              <ThreeStrandedButton
+                onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}
+                isBtnDisabled={currentUser == null}
+                editorMode={editorMode}
+              />
+            )}
           </div>
         </div>
 
         {/* Page Authors */}
-        { (!isCompactMode && !isUserPage) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none">
+        { (!isCompactMode && !isUserPage && !isPageNotFound && !isPageForbidden) && (
+          <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} />
             </li>

+ 49 - 15
src/client/js/components/Navbar/ThreeStrandedButton.jsx

@@ -1,32 +1,60 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
 
 const ThreeStrandedButton = (props) => {
+  const { t, isBtnDisabled, editorMode } = props;
 
-  const { t } = props;
 
   function threeStrandedButtonClickedHandler(viewType) {
+    if (isBtnDisabled) {
+      return;
+    }
     if (props.onThreeStrandedButtonClicked != null) {
       props.onThreeStrandedButtonClicked(viewType);
     }
   }
 
   return (
-    <div className="btn-group grw-three-stranded-button" role="group " aria-label="three-stranded-button">
-      <button type="button" className="btn btn-outline-primary view-button" onClick={() => { threeStrandedButtonClickedHandler('view') }}>
-        <i className="icon-control-play icon-fw" />
-        { t('view') }
-      </button>
-      <button type="button" className="btn btn-outline-primary edit-button" onClick={() => { threeStrandedButtonClickedHandler('edit') }}>
-        <i className="icon-note icon-fw" />
-        { t('Edit') }
-      </button>
-      <button type="button" className="btn btn-outline-primary hackmd-button" onClick={() => { threeStrandedButtonClickedHandler('hackmd') }}>
-        <i className="fa fa-fw fa-file-text-o" />
-        { t('hackmd.hack_md') }
-      </button>
-    </div>
+    <>
+      <div
+        className="btn-group grw-three-stranded-button"
+        role="group"
+        aria-label="three-stranded-button"
+        id="grw-three-stranded-button"
+      >
+        <button
+          type="button"
+          className={`btn btn-outline-primary view-button ${editorMode === 'view' && 'active'} ${isBtnDisabled && 'disabled'}`}
+          onClick={() => { threeStrandedButtonClickedHandler('view') }}
+        >
+          <i className="icon-control-play icon-fw grw-three-stranded-button-icon" />
+          { t('view') }
+        </button>
+        <button
+          type="button"
+          className={`btn btn-outline-primary edit-button ${editorMode === 'edit' && 'active'} ${isBtnDisabled && 'disabled'}`}
+          onClick={() => { threeStrandedButtonClickedHandler('edit') }}
+        >
+          <i className="icon-note icon-fw grw-three-stranded-button-icon" />
+          { t('Edit') }
+        </button>
+        <button
+          type="button"
+          className={`btn btn-outline-primary hackmd-button ${editorMode === 'hackmd' && 'active'} ${isBtnDisabled && 'disabled'}`}
+          onClick={() => { threeStrandedButtonClickedHandler('hackmd') }}
+        >
+          <i className="fa fa-fw fa-file-text-o grw-three-stranded-button-icon" />
+          { t('hackmd.hack_md') }
+        </button>
+      </div>
+      {isBtnDisabled && (
+        <UncontrolledTooltip placement="top" target="grw-three-stranded-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+    </>
   );
 
 };
@@ -34,6 +62,12 @@ const ThreeStrandedButton = (props) => {
 ThreeStrandedButton.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   onThreeStrandedButtonClicked: PropTypes.func,
+  isBtnDisabled: PropTypes.bool,
+  editorMode: PropTypes.string,
+};
+
+ThreeStrandedButton.defaultProps = {
+  isBtnDisabled: false,
 };
 
 export default withTranslation()(ThreeStrandedButton);

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

@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomNavigation from './CustomNavigation';
+import PageList from './PageList';
+import PageTimeline from './PageTimeline';
+
+const NotFoundPage = (props) => {
+  const { t } = props;
+
+  const navTabMapping = {
+    pagelist: {
+      Icon: PageListIcon,
+      Content: PageList,
+      i18n: t('page_list'),
+      index: 0,
+    },
+    timeLine: {
+      Icon: TimeLineIcon,
+      Content: PageTimeline,
+      i18n: t('Timeline View'),
+      index: 1,
+    },
+  };
+
+  return (
+    <div className="mt-5 d-edit-none">
+      <CustomNavigation 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={`${isMobile && 'page-mobile'}`}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
 
         { isLoggedIn && (

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

@@ -0,0 +1,43 @@
+import React from 'react';
+import { TabContent, TabPane } from 'reactstrap';
+import propTypes from 'prop-types';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import NavigationContainer from '../../services/NavigationContainer';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+
+
+const DisplaySwitcher = (props) => {
+  const { navigationContainer } = props;
+  const { editorMode } = navigationContainer.state;
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId="view">
+          <Page />
+        </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,
+};
+
+
+export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer]);

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

@@ -0,0 +1,45 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+const NotFoundAlert = (props) => {
+  const { t, isHidden } = props;
+  function clickHandler(viewType) {
+    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>
+        <button
+          type="button"
+          className="m-1 pl-3 pr-3 btn bg-info text-white"
+          onClick={() => { clickHandler('edit') }}
+        >
+          <i className="icon-note icon-fw" />
+          {t('not_found_page.Create Page')}
+        </button>
+      </div>
+    </div>
+  );
+};
+
+
+NotFoundAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  onPageCreateClicked: PropTypes.func,
+  isHidden: PropTypes.bool.isRequired,
+};
+
+export default withTranslation()(NotFoundAlert);

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

@@ -17,7 +17,9 @@ 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;
@@ -98,6 +100,24 @@ const PageManagement = (props) => {
   //   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 (
       <>
@@ -108,9 +128,9 @@ const PageManagement = (props) => {
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
         </button>
         <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>
+          <i className="icon-fw"><PresentationIcon /></i> { t('Presentation Mode') }
         </button>
-        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+        <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 */}
@@ -173,10 +193,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>
       </>
     );
@@ -187,12 +207,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>
       </>
@@ -204,7 +224,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>
@@ -225,6 +245,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);

+ 1 - 9
src/client/js/components/Page/RevisionPathControls.jsx

@@ -3,8 +3,6 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
-import { isTrashPage } from '@commons/util/path-utils';
-
 import CopyDropdown from './CopyDropdown';
 
 const RevisionPathControls = (props) => {
@@ -15,19 +13,13 @@ const RevisionPathControls = (props) => {
   };
 
   const {
-    pagePath, pageId, isPageForbidden,
+    pagePath, pageId,
   } = props;
 
-  const isPageInTrash = isTrashPage(pagePath);
 
   return (
     <>
       <CopyDropdown pagePath={pagePath} pageId={pageId} buttonStyle={buttonStyle} />
-      { !isPageInTrash && !isPageForbidden && (
-        <a href="#edit" className="d-edit-none text-muted btn btn-secondary bg-transparent btn-edit border-0" style={buttonStyle}>
-          <i className="icon-note" />
-        </a>
-      ) }
     </>
   );
 };

+ 5 - 5
src/client/js/components/Page/TagEditModal.jsx

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 import TagsInput from './TagsInput';
@@ -37,15 +37,15 @@ function TagEditModal(props) {
   return (
     <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
-          Edit Tags
+        Edit Tags
       </ModalHeader>
       <ModalBody>
         <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
       </ModalBody>
       <ModalFooter>
-        <Button color="primary" onClick={handleSubmit}>
-            Done
-        </Button>
+        <button type="button" className="btn btn-primary" onClick={handleSubmit}>
+          Done
+        </button>
       </ModalFooter>
     </Modal>
   );

+ 18 - 21
src/client/js/components/Page/TagLabels.jsx

@@ -28,12 +28,12 @@ class TagLabels extends React.Component {
 
   /**
    * @return tags data
-   *   1. pageContainer.state.tags if isEditorMode is false
-   *   2. editorContainer.state.tags if isEditorMode is true
+   *   1. pageContainer.state.tags if editorMode is view
+   *   2. editorContainer.state.tags if editorMode is edit
    */
-  getEditTargetData() {
-    const { isEditorMode } = this.props;
-    return (isEditorMode) ? this.props.editorContainer.state.tags : this.props.pageContainer.state.tags;
+  getTagData() {
+    const { editorContainer, pageContainer, editorMode } = this.props;
+    return (editorMode === 'edit') ? editorContainer.state.tags : pageContainer.state.tags;
   }
 
   openEditorModal() {
@@ -44,23 +44,24 @@ class TagLabels extends React.Component {
     this.setState({ isTagEditModalShown: false });
   }
 
-  async tagsUpdatedHandler(tags) {
-    const { appContainer, editorContainer, isEditorMode } = this.props;
+  async tagsUpdatedHandler(newTags) {
+    const {
+      appContainer, editorContainer, pageContainer, editorMode,
+    } = this.props;
 
-    // only update tags in editorContainer
-    if (isEditorMode) {
-      return editorContainer.setState({ tags });
-    }
+    const { pageId } = pageContainer.state;
 
-    // post api request and update tags
-    const { pageContainer } = this.props;
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === 'edit') {
+      return editorContainer.setState({ tags: newTags });
+    }
 
     try {
-      const { pageId } = pageContainer.state;
-      await appContainer.apiPost('/tags.update', { pageId, tags });
+      const { tags } = await appContainer.apiPost('/tags.update', { pageId, tags: newTags });
 
       // update pageContainer.state
       pageContainer.setState({ tags });
+      // update editorContainer.state
       editorContainer.setState({ tags });
 
       toastSuccess('updated tags successfully');
@@ -72,7 +73,7 @@ class TagLabels extends React.Component {
 
 
   render() {
-    const tags = this.getEditTargetData();
+    const tags = this.getTagData();
 
     return (
       <>
@@ -113,11 +114,7 @@ TagLabels.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
-  isEditorMode: PropTypes.bool,
-};
-
-TagLabels.defaultProps = {
-  isEditorMode: false,
+  editorMode: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(TagLabelsWrapper);

+ 41 - 0
src/client/js/components/PageAccessories.jsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import PageAccessoriesModalControl from './PageAccessoriesModalControl';
+import PageAccessoriesModal from './PageAccessoriesModal';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+
+const PageAccessories = (props) => {
+  const { appContainer, pageAccessoriesContainer } = props;
+  const isGuestUserMode = appContainer.currentUser == null;
+
+  // not render only when this page is shared and user is not login.
+  if (appContainer.isSharedUser && isGuestUserMode) {
+    return null;
+  }
+
+  return (
+    <>
+      <PageAccessoriesModalControl isGuestUserMode={isGuestUserMode} />
+      <PageAccessoriesModal
+        isGuestUserMode={isGuestUserMode}
+        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
+        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
+      />
+    </>
+  );
+};
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
+
+PageAccessories.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+};
+
+export default PageAccessoriesWrapper;

+ 83 - 101
src/client/js/components/PageAccessoriesModal.jsx

@@ -1,15 +1,14 @@
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
 import PropTypes from 'prop-types';
 
 import {
-  Modal, ModalBody, ModalHeader, Nav, NavItem, NavLink, TabContent, TabPane,
+  Modal, ModalBody, ModalHeader, TabContent, TabPane,
 } from 'reactstrap';
 
 import { withTranslation } from 'react-i18next';
-
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import RecentChangesIcon from './Icons/RecentChangesIcon';
+import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 
@@ -20,123 +19,106 @@ import PageTimeline from './PageTimeline';
 import PageList from './PageList';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
-
-
-const navTabMapping = {
-  pagelist: {
-    icon: <PageListIcon />,
-    i18n: 'page_list',
-    index: 0,
-  },
-  timeline:  {
-    icon: <TimeLineIcon />,
-    i18n: 'Timeline View',
-    index: 1,
-  },
-  pageHistory: {
-    icon: <RecentChangesIcon />,
-    i18n: 'History',
-    index: 2,
-  },
-  attachment: {
-    icon: <AttachmentIcon />,
-    i18n: 'attachment_data',
-    index: 3,
-  },
-  shareLink: {
-    icon: <ShareLinkIcon />,
-    i18n: 'share_links.share_link_management',
-    index: 4,
-  },
-};
+import { CustomNav } from './CustomNavigation';
+import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
-  const { t, pageAccessoriesContainer } = props;
+  const {
+    t, pageAccessoriesContainer, onClose, isGuestUserMode,
+  } = props;
   const { switchActiveTab } = pageAccessoriesContainer;
-  const { activeTab } = pageAccessoriesContainer.state;
-
-  const [sliderWidth, setSliderWidth] = useState(null);
-  const [sliderMarginLeft, setSliderMarginLeft] = useState(null);
-
-  function closeModalHandler() {
-    if (props.onClose == null) {
-      return;
-    }
-    props.onClose();
-  }
-
-  // Might make this dynamic for px, %, pt, em
-  function getPercentage(min, max) {
-    return min / max * 100;
-  }
-
-  useEffect(() => {
-    if (activeTab === '') {
-      return;
-    }
-
-    const navTitle = document.getElementById('nav-title');
-    const navTabs = document.querySelectorAll('li.nav-link');
-
-    if (navTitle == null || navTabs == null) {
+  const { activeTab, activeComponents } = pageAccessoriesContainer.state;
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        i18n: t('page_list'),
+        index: 0,
+      },
+      timeline:  {
+        Icon: TimeLineIcon,
+        i18n: t('Timeline View'),
+        index: 1,
+      },
+      pageHistory: {
+        Icon: HistoryIcon,
+        i18n: t('History'),
+        index: 2,
+      },
+      attachment: {
+        Icon: AttachmentIcon,
+        i18n: t('attachment_data'),
+        index: 3,
+      },
+      shareLink: {
+        Icon: ShareLinkIcon,
+        i18n: t('share_links.share_link_management'),
+        index: 4,
+        isLinkEnabled: v => !isGuestUserMode,
+      },
+    };
+  }, [t, isGuestUserMode]);
+
+  const closeModalHandler = useCallback(() => {
+    if (onClose == null) {
       return;
     }
-
-    let tempML = 0;
-
-    const styles = [].map.call(navTabs, (el) => {
-      const width = getPercentage(el.offsetWidth, navTitle.offsetWidth);
-      const marginLeft = tempML;
-      tempML += width;
-      return { width, marginLeft };
-    });
-
-    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
-
-    setSliderWidth(width);
-    setSliderMarginLeft(marginLeft);
-
-  }, [activeTab]);
-
+    onClose();
+  }, [onClose]);
+
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+  };
+
+  const buttons = (
+    <span>
+      {/* change order because of `float: right` by '.close' class */}
+      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={expandWindow}
+        contractWindow={contractWindow}
+      />
+    </span>
+  );
 
   return (
     <React.Fragment>
-      <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className="grw-page-accessories-modal">
-        {/* [TODO: insert a modal header and move nav tabs there  by gw-3890] */}
-        <ModalHeader className="p-0" toggle={closeModalHandler}>
-          <Nav className="nav-title" id="nav-title">
-            {Object.entries(navTabMapping).map(([key, value]) => {
-              return (
-                <NavItem key={key} type="button" className={`p-0 nav-link ${activeTab === key && 'active'}`}>
-                  <NavLink onClick={() => { switchActiveTab(key) }}>
-                    {value.icon}
-                    {t(value.i18n)}
-                  </NavLink>
-                </NavItem>
-              );
-            })}
-          </Nav>
-          <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
+      <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className={`grw-page-accessories-modal ${isWindowExpanded && 'grw-modal-expanded'} `}>
+        <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
+          <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} />
         </ModalHeader>
         <ModalBody className="overflow-auto grw-modal-body-style p-0">
+          {/* Do not use CustomTabContent because of performance problem:
+              the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
           <TabContent activeTab={activeTab} className="p-5">
             <TabPane tabId="pagelist">
-              {pageAccessoriesContainer.state.activeComponents.has('pagelist') && <PageList />}
+              {activeComponents.has('pagelist') && <PageList />}
             </TabPane>
             <TabPane tabId="timeline">
-              {pageAccessoriesContainer.state.activeComponents.has('timeline') && <PageTimeline /> }
+              {activeComponents.has('timeline') && <PageTimeline /> }
             </TabPane>
             <TabPane tabId="pageHistory">
               <div className="overflow-auto">
-                {pageAccessoriesContainer.state.activeComponents.has('pageHistory') && <PageHistory /> }
+                {activeComponents.has('pageHistory') && <PageHistory /> }
               </div>
             </TabPane>
             <TabPane tabId="attachment">
-              {pageAccessoriesContainer.state.activeComponents.has('attachment') && <PageAttachment />}
-            </TabPane>
-            <TabPane tabId="shareLink">
-              {pageAccessoriesContainer.state.activeComponents.has('shareLink') && <ShareLink />}
+              {activeComponents.has('attachment') && <PageAttachment />}
             </TabPane>
+            {!isGuestUserMode && (
+              <TabPane tabId="shareLink">
+                {activeComponents.has('shareLink') && <ShareLink />}
+              </TabPane>
+            )}
           </TabContent>
         </ModalBody>
       </Modal>
@@ -151,8 +133,8 @@ const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal,
 
 PageAccessoriesModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  isGuestUserMode: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
 };

+ 89 - 0
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -0,0 +1,89 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import { UncontrolledTooltip } from 'reactstrap';
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import HistoryIcon from './Icons/HistoryIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+import SeenUserInfo from './User/SeenUserInfo';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+const PageAccessoriesModalControl = (props) => {
+  const { t, pageAccessoriesContainer, isGuestUserMode } = props;
+
+  return (
+    <div className="grw-page-accessories-control d-flex align-items-center pb-1">
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pagelist')}
+      >
+        <PageListIcon />
+      </button>
+
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('timeline')}
+      >
+        <TimeLineIcon />
+      </button>
+
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
+      >
+        <HistoryIcon />
+      </button>
+
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
+      >
+        <AttachmentIcon />
+      </button>
+
+      <div id="shareLink-btn-wrapper-for-tooltip">
+        <button
+          type="button"
+          className={`btn btn-link grw-btn-page-accessories ${isGuestUserMode && 'disabled'}`}
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
+        >
+          <ShareLinkIcon />
+        </button>
+      </div>
+      {isGuestUserMode && (
+        <UncontrolledTooltip placement="top" target="shareLink-btn-wrapper-for-tooltip" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <span className="border-left grw-border-vr mx-1">&nbsp;</span>
+
+      <SeenUserInfo />
+    </div>
+  );
+};
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+
+PageAccessoriesModalControl.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
+  isGuestUserMode: PropTypes.bool.isRequired,
+};
+
+export default withTranslation()(PageAccessoriesModalControlWrapper);

+ 6 - 11
src/client/js/components/PageAttachment.jsx

@@ -17,8 +17,8 @@ class PageAttachment extends React.Component {
 
     this.state = {
       activePage: 1,
-      limit: 10,
       totalAttachments: 0,
+      limit: null,
       attachments: [],
       inUse: {},
       attachmentToDelete: null,
@@ -34,27 +34,24 @@ class PageAttachment extends React.Component {
 
   async handlePage(selectedPage) {
     const { pageId } = this.props.pageContainer.state;
-    const { limit } = this.state;
-    const offset = (selectedPage - 1) * limit;
-    const activePage = selectedPage;
+    const page = selectedPage;
 
     if (!pageId) { return }
 
-    const res = await this.props.appContainer.apiv3Get('/attachment/list', {
-      pageId, limit, offset,
-    });
+    const res = await this.props.appContainer.apiv3Get('/attachment/list', { pageId, page });
     const attachments = res.data.paginateResult.docs;
     const totalAttachments = res.data.paginateResult.totalDocs;
+    const pagingLimit = res.data.paginateResult.limit;
 
     const inUse = {};
 
     for (const attachment of attachments) {
       inUse[attachment._id] = this.checkIfFileInUse(attachment);
     }
-
     this.setState({
-      activePage,
+      activePage: selectedPage,
       totalAttachments,
+      limit: pagingLimit,
       attachments,
       inUse,
     });
@@ -114,11 +111,9 @@ class PageAttachment extends React.Component {
 
 
   render() {
-
     const { t } = this.props;
     if (this.state.attachments.length === 0) {
       return t('No_attachments_yet');
-
     }
 
     let deleteAttachmentModal = '';

+ 17 - 16
src/client/js/components/PageComment/Comment.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 
 import { UncontrolledTooltip } from 'reactstrap';
@@ -16,6 +17,7 @@ import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
 import CommentControl from './CommentControl';
+import HistoryIcon from '../Icons/HistoryIcon';
 
 /**
  *
@@ -38,7 +40,6 @@ class Comment extends React.PureComponent {
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
-    this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
@@ -109,11 +110,6 @@ class Comment extends React.PureComponent {
     return className;
   }
 
-  getRevisionLabelClassName() {
-    return `page-comment-revision badge ${
-      this.isCurrentRevision() ? 'badge-primary' : 'badge-secondary'}`;
-  }
-
   deleteBtnClickedHandler() {
     this.props.deleteBtnClicked(this.props.comment);
   }
@@ -155,6 +151,7 @@ class Comment extends React.PureComponent {
   }
 
   render() {
+    const { t } = this.props;
     const comment = this.props.comment;
     const commentId = comment._id;
     const creator = comment.creator;
@@ -166,8 +163,6 @@ class Comment extends React.PureComponent {
     const rootClassName = this.getRootClassName(comment);
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
-    const revFirst8Letters = comment.revision.substr(-8);
-    const revisionLavelClassName = this.getRevisionLabelClassName();
 
     const editedDateId = `editedDate-${comment._id}`;
     const editedDateFormatted = isEdited
@@ -176,7 +171,6 @@ class Comment extends React.PureComponent {
 
     return (
       <React.Fragment>
-
         {this.state.isReEdit ? (
           <CommentEditor
             growiRenderer={this.props.growiRenderer}
@@ -206,10 +200,17 @@ class Comment extends React.PureComponent {
                     <span id={editedDateId}>&nbsp;(edited)</span>
                     <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
                   </>
-                ) }
-                <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
+                )}
+                <span className="ml-2">
+                  <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
+                    <HistoryIcon />
+                  </a>
+                  <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
+                    {t('page_comment.display_the_page_when_posting_this_comment')}
+                  </UncontrolledTooltip>
+                </span>
               </div>
-              { this.checkPermissionToControlComment() && (
+              {this.checkPermissionToControlComment() && (
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
@@ -217,9 +218,8 @@ class Comment extends React.PureComponent {
               ) }
             </div>
           </div>
-          )
-        }
-
+        )
+      }
       </React.Fragment>
     );
   }
@@ -232,6 +232,7 @@ class Comment extends React.PureComponent {
 const CommentWrapper = withUnstatedContainers(Comment, [AppContainer, PageContainer]);
 
 Comment.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
@@ -240,4 +241,4 @@ Comment.propTypes = {
   deleteBtnClicked: PropTypes.func.isRequired,
 };
 
-export default CommentWrapper;
+export default withTranslation()(CommentWrapper);

+ 2 - 0
src/client/js/components/PageComment/CommentEditor.jsx

@@ -346,6 +346,7 @@ class CommentEditor extends React.Component {
             </label>
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
+
             { this.state.hasSlackConfig
               && (
               <div className="form-inline align-self-center mr-md-2">
@@ -354,6 +355,7 @@ class CommentEditor extends React.Component {
                   slackChannels={commentContainer.state.slackChannels}
                   onEnabledFlagChange={this.onSlackEnabledFlagChange}
                   onChannelChange={this.onSlackChannelsChange}
+                  id="idForComment"
                 />
               </div>
               )

+ 1 - 1
src/client/js/components/PageComments.jsx

@@ -170,7 +170,7 @@ class PageComments extends React.Component {
               className="btn-comment-reply"
               onClick={() => { return this.replyButtonClickedHandler(commentId) }}
             >
-              <i className="icon-fw icon-action-redo"></i> Reply
+              <i className="icon-fw icon-action-undo"></i> Reply
             </Button>
           </div>
         )}

+ 59 - 2
src/client/js/components/PageEditor/EditorNavbarBottom.jsx

@@ -1,9 +1,13 @@
 import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 
-import { Collapse } from 'reactstrap';
+import { Collapse, Button } from 'reactstrap';
 
 import NavigationContainer from '../../services/NavigationContainer';
+import EditorContainer from '../../services/EditorContainer';
+import AppContainer from '../../services/AppContainer';
+import SlackNotification from '../SlackNotification';
+import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SavePageControls from '../SavePageControls';
@@ -14,6 +18,9 @@ const EditorNavbarBottom = (props) => {
 
   const [isExpanded, setExpanded] = useState(false);
 
+  const [isSlackExpanded, setSlackExpanded] = useState(false);
+  const hasSlackConfig = props.appContainer.getConfig().hasSlackConfig;
+
   const {
     navigationContainer,
   } = props;
@@ -27,6 +34,14 @@ const EditorNavbarBottom = (props) => {
     </button>
   );
 
+  const slackEnabledFlagChangedHandler = (isSlackEnabled) => {
+    props.editorContainer.setState({ isSlackEnabled });
+  };
+
+  const slackChannelsChangedHandler = (slackChannels) => {
+    props.editorContainer.setState({ slackChannels });
+  };
+
   // eslint-disable-next-line react/prop-types
   const renderExpandButton = () => (
     <div className="d-md-none ml-2">
@@ -45,12 +60,52 @@ const EditorNavbarBottom = (props) => {
 
   return (
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
+      {/* Collapsed SlackNotification */}
+      {hasSlackConfig && (
+        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd}>
+          <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
+            <SlackNotification
+              isSlackEnabled={props.editorContainer.state.isSlackEnabled}
+              slackChannels={props.editorContainer.state.slackChannels}
+              onEnabledFlagChange={slackEnabledFlagChangedHandler}
+              onChannelChange={slackChannelsChangedHandler}
+              id="idForEditorNavbarBottomForMobile"
+              popUp
+            />
+          </nav>
+        </Collapse>
+        )
+      }
       <div className={`navbar navbar-expand border-top px-2 ${additionalClasses.join(' ')}`}>
         <form className="form-inline">
           { isDrawerMode && renderDrawerButton() }
           { isOptionsSelectorEnabled && !isDeviceSmallerThanMd && <OptionsSelector /> }
         </form>
         <form className="form-inline ml-auto">
+          {/* Responsive Design for the SlackNotification */}
+          {/* Button or the normal Slack banner */}
+          {hasSlackConfig && (isDeviceSmallerThanMd ? (
+            <Button
+              className="grw-btn-slack border mr-2"
+              onClick={() => (setSlackExpanded(!isSlackExpanded))}
+            >
+              <div className="grw-slack-logo">
+                <SlackLogo />
+                <span className="grw-btn-slack-triangle fa fa-caret-up ml-2"></span>
+              </div>
+            </Button>
+          ) : (
+            <div className="mr-2">
+              <SlackNotification
+                isSlackEnabled={props.editorContainer.state.isSlackEnabled}
+                slackChannels={props.editorContainer.state.slackChannels}
+                onEnabledFlagChange={slackEnabledFlagChangedHandler}
+                onChannelChange={slackChannelsChangedHandler}
+                id="idForEditorNavbarBottom"
+                popUp={false}
+              />
+            </div>
+          ))}
           <SavePageControls />
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
@@ -73,6 +128,8 @@ const EditorNavbarBottom = (props) => {
 
 EditorNavbarBottom.propTypes = {
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer, EditorContainer, AppContainer]);

+ 7 - 18
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -13,6 +13,7 @@ import { debounce } from 'throttle-debounce';
 
 import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
 import MarkdownTable from '../../models/MarkdownTable';
+import ExpandOrContractButton from '../ExpandOrContractButton';
 
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
@@ -397,15 +398,6 @@ export default class HandsontableModal extends React.PureComponent {
     }
   }
 
-  renderExpandOrContractButton() {
-    const iconClassName = this.state.isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen';
-    return (
-      <button type="button" className="close" onClick={this.state.isWindowExpanded ? this.contractWindow : this.expandWindow}>
-        <i className={iconClassName} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
-      </button>
-    );
-  }
-
   renderCloseButton() {
     return (
       <button type="button" className="close" onClick={this.cancel} aria-label="Close">
@@ -415,24 +407,21 @@ export default class HandsontableModal extends React.PureComponent {
   }
 
   render() {
-    const dialogClassNames = ['handsontable-modal'];
-    if (this.state.isWindowExpanded) {
-      dialogClassNames.push('handsontable-modal-expanded');
-    }
-
-    const dialogClassName = dialogClassNames.join(' ');
 
-    // eslint-disable-next-line no-unused-vars
     const buttons = (
       <span>
         {/* change order because of `float: right` by '.close' class */}
         {this.renderCloseButton()}
-        {this.renderExpandOrContractButton()}
+        <ExpandOrContractButton
+          isWindowExpanded={this.state.isWindowExpanded}
+          contractWindow={this.contractWindow}
+          expandWindow={this.expandWindow}
+        />
       </span>
     );
 
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg" className={dialogClassName}>
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg" className={`handsontable-modal ${this.state.isWindowExpanded && 'grw-modal-expanded'}`}>
         <ModalHeader tag="h4" toggle={this.cancel} close={buttons} className="bg-primary text-light">
           Edit Table
         </ModalHeader>

+ 0 - 48
src/client/js/components/PageEditor/PagePathNavForEditor.jsx

@@ -1,48 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import LinkedPagePath from '@commons/models/linked-page-path';
-import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
-
-import RevisionPathControls from '../Page/RevisionPathControls';
-import TagLabels from '../Page/TagLabels';
-
-const PagePathNavForEditor = (props) => {
-  const { pageId, path } = props.pageContainer.state;
-
-  const linkedPagePath = new LinkedPagePath(path);
-  const pagePathHierarchicalLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-
-  return (
-    <div className="grw-page-path-nav-for-edit">
-      <span className="d-flex align-items-center flex-wrap">
-        <h3 className="mb-0 grw-page-path-link">{pagePathHierarchicalLink}</h3>
-        <RevisionPathControls
-          pageId={pageId}
-          pagePath={path}
-        />
-      </span>
-      <TagLabels isEditorMode />
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PagePathNavForEditorWrapper = withUnstatedContainers(PagePathNavForEditor, [AppContainer, PageContainer]);
-
-
-PagePathNavForEditor.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withTranslation()(PagePathNavForEditorWrapper);

+ 20 - 2
src/client/js/components/PageEditorByHackmd.jsx

@@ -230,9 +230,9 @@ class PageEditorByHackmd extends React.Component {
     const hackmdUri = this.getHackmdUri();
     const { pageContainer, t } = this.props;
     const {
-      revisionId, revisionIdHackmdSynced, remoteRevisionId,
+      revisionId, revisionIdHackmdSynced, remoteRevisionId, pageId,
     } = pageContainer.state;
-
+    const isPageNotFound = pageId == null;
 
     let content;
 
@@ -243,6 +243,24 @@ class PageEditorByHackmd extends React.Component {
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> { t('hackmd.not_set_up')}</p>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_associate_with_growi_to_use_hackmd_refer_to_this') }} />
+        </div>
+      );
+    }
+
+    /*
+    * used HackMD from NotFound Page
+    */
+    else if (isPageNotFound) {
+      content = (
+        <div className="text-center">
+          <p className="hackmd-status-label">
+            <i className="fa fa-file-text mr-2" />
+            { t('hackmd.used_for_not_found') }
+          </p>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_make_page') }} />
         </div>
       );
     }

+ 11 - 10
src/client/js/components/PageHistory.jsx

@@ -14,9 +14,12 @@ import PaginationWrapper from './PaginationWrapper';
 
 const logger = loggerFactory('growi:PageHistory');
 
-
 function PageHistory(props) {
   const { pageHistoryContainer } = props;
+  const { getPreviousRevision, onDiffOpenClicked } = pageHistoryContainer;
+  const {
+    activePage, totalPages, pagingLimit, revisions, diffOpened,
+  } = pageHistoryContainer.state;
 
   const handlePage = useCallback(async(selectedPage) => {
     try {
@@ -50,27 +53,25 @@ function PageHistory(props) {
     });
   }
 
-
   function pager() {
     return (
       <PaginationWrapper
-        activePage={pageHistoryContainer.state.activePage}
+        activePage={activePage}
         changePage={handlePage}
-        totalItemsCount={pageHistoryContainer.state.totalPages}
-        pagingLimit={pageHistoryContainer.state.pagingLimit}
+        totalItemsCount={totalPages}
+        pagingLimit={pagingLimit}
         align="center"
       />
     );
   }
 
-
   return (
     <div>
       <PageRevisionList
-        revisions={pageHistoryContainer.state.revisions}
-        diffOpened={pageHistoryContainer.state.diffOpened}
-        getPreviousRevision={pageHistoryContainer.getPreviousRevision}
-        onDiffOpenClicked={pageHistoryContainer.onDiffOpenClicked}
+        revisions={revisions}
+        diffOpened={diffOpened}
+        getPreviousRevision={getPreviousRevision}
+        onDiffOpenClicked={onDiffOpenClicked}
       />
       {pager()}
     </div>

+ 1 - 1
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -39,7 +39,7 @@ export default class RevisionDiff extends React.Component {
 
     const diffView = { __html: diffViewHTML };
     // eslint-disable-next-line react/no-danger
-    return <div className="revision-history-diff d-table w-100" dangerouslySetInnerHTML={diffView} />;
+    return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
   }
 
 }

+ 30 - 14
src/client/js/components/PageList.jsx

@@ -1,5 +1,6 @@
 import React, { useEffect, useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 
 import Page from './PageList/Page';
 import { withUnstatedContainers } from './UnstatedUtils';
@@ -11,37 +12,35 @@ import PaginationWrapper from './PaginationWrapper';
 
 
 const PageList = (props) => {
-  const { appContainer, pageContainer } = props;
+  const { appContainer, pageContainer, t } = props;
   const { path } = pageContainer.state;
   const [pages, setPages] = useState(null);
-  const [isLoading, setIsLoading] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
 
   const [activePage, setActivePage] = useState(1);
   const [totalPages, setTotalPages] = useState(0);
-  const [limit, setLimit] = useState(appContainer.getConfig().recentCreatedLimit);
-  const [offset, setOffset] = useState(0);
+  const [limit, setLimit] = useState(null);
 
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
-    setOffset((selectedPageNumber - 1) * limit);
   }
 
   const updatePageList = useCallback(async() => {
-    const res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+    const page = activePage;
+    const res = await appContainer.apiv3Get('/pages/list', { path, page });
 
     setPages(res.data.pages);
-    setIsLoading(true);
+    setIsLoading(false);
     setTotalPages(res.data.totalCount);
     setLimit(res.data.limit);
-    setOffset(res.data.offset);
-  }, [appContainer, path, limit, offset]);
+  }, [appContainer, path, activePage]);
 
   useEffect(() => {
     updatePageList();
   }, [updatePageList]);
 
 
-  if (isLoading === false) {
+  if (isLoading) {
     return (
       <div className="wiki">
         <div className="text-muted test-center">
@@ -51,15 +50,24 @@ const PageList = (props) => {
     );
   }
 
+  const liClasses = props.liClasses.join(' ');
   const pageList = pages.map(page => (
-    <li key={page._id} className="mb-3">
+    <li key={page._id} className={liClasses}>
       <Page page={page} />
     </li>
   ));
+  if (pageList.length === 0) {
+    return (
+      <div className="mt-2">
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('custom_navigation.no_page_list', { path }) }} />
+      </div>
+    );
+  }
 
   return (
-    <div className="page-list-container-create">
-      <ul className="page-list-ul page-list-ul-flat ml-n4">
+    <div className="page-list">
+      <ul className="page-list-ul page-list-ul-flat">
         {pageList}
       </ul>
       <PaginationWrapper
@@ -77,10 +85,18 @@ const PageList = (props) => {
 
 const PageListWrapper = withUnstatedContainers(PageList, [AppContainer, PageContainer]);
 
+const PageListTranslation = withTranslation()(PageListWrapper);
+
 
 PageList.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer),
   pageContainer: PropTypes.instanceOf(PageContainer),
+
+  liClasses: PropTypes.arrayOf(PropTypes.string),
+};
+PageList.defaultProps = {
+  liClasses: ['mb-3'],
 };
 
-export default PageListWrapper;
+export default PageListTranslation;

+ 97 - 0
src/client/js/components/PageList/BookmarkList.jsx

@@ -0,0 +1,97 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+
+import AppContainer from '../../services/AppContainer';
+import { toastError } from '../../util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import Page from './Page';
+
+const logger = loggerFactory('growi:BookmarkList');
+
+const BookmarkList = (props) => {
+  const { t, appContainer, userId } = props;
+
+  const [pages, setPages] = useState([]);
+
+  const [activePage, setActivePage] = useState(1);
+  const [totalItemsCount, setTotalItemsCount] = useState(0);
+  const [pagingLimit, setPagingLimit] = useState(10);
+
+  const setPageNumber = (selectedPageNumber) => {
+    setActivePage(selectedPageNumber);
+  };
+
+  const getMyBookmarkList = useCallback(async() => {
+    const page = activePage;
+
+    try {
+      const res = await appContainer.apiv3Get(`/bookmarks/${userId}`, { page });
+      const { paginationResult } = res.data;
+
+      setPages(paginationResult.docs);
+      setTotalItemsCount(paginationResult.totalDocs);
+      setPagingLimit(paginationResult.limit);
+    }
+    catch (error) {
+      logger.error('failed to fetch data', error);
+      toastError(error, 'Error occurred in bookmark page list');
+    }
+  }, [appContainer, activePage, userId]);
+
+  useEffect(() => {
+    getMyBookmarkList();
+  }, [getMyBookmarkList]);
+
+  /**
+   * generate Elements of Page
+   *
+   * @param {any} pages Array of pages Model Obj
+   *
+   */
+  const generatePageList = pages.map(page => (
+    <li key={`my-bookmarks:${page._id}`} className="mt-4">
+      <Page page={page.page} />
+    </li>
+  ));
+
+  return (
+    <div className="bookmarks-list-container">
+      {pages.length === 0 ? t('No bookmarks yet') : (
+        <>
+          <ul className="page-list-ul page-list-ul-flat mb-3">
+            {generatePageList}
+          </ul>
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setPageNumber}
+            totalItemsCount={totalItemsCount}
+            pagingLimit={pagingLimit}
+            align="center"
+            size="sm"
+          />
+        </>
+      )}
+    </div>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const BookmarkListWrapper = withUnstatedContainers(BookmarkList, [AppContainer]);
+
+BookmarkList.propTypes = {
+  t: PropTypes.func.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userId: PropTypes.string.isRequired,
+};
+
+export default withTranslation()(BookmarkListWrapper);

+ 15 - 9
src/client/js/components/PageTimeline.jsx

@@ -16,11 +16,10 @@ class PageTimeline extends React.Component {
   constructor(props) {
     super(props);
 
-    const { appContainer } = this.props;
     this.state = {
       activePage: 1,
       totalPageItems: 0,
-      limit: appContainer.getConfig().recentCreatedLimit,
+      limit: null,
 
       // TODO: remove after when timeline is implemented with React and inject data with props
       pages: this.props.pages,
@@ -33,17 +32,17 @@ class PageTimeline extends React.Component {
   async handlePage(selectedPage) {
     const { appContainer, pageContainer } = this.props;
     const { path } = pageContainer.state;
-    const { limit } = this.state;
-    const offset = (selectedPage - 1) * limit;
-    const activePage = selectedPage;
+    const page = selectedPage;
 
-    const res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+    const res = await appContainer.apiv3Get('/pages/list', { path, page });
     const totalPageItems = res.data.totalCount;
     const pages = res.data.pages;
+    const pagingLimit = res.data.limit;
     this.setState({
-      activePage,
+      activePage: selectedPage,
       totalPageItems,
       pages,
+      limit: pagingLimit,
     });
   }
 
@@ -61,9 +60,16 @@ class PageTimeline extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
     const { pages } = this.state;
-    if (pages == null) {
-      return <React.Fragment></React.Fragment>;
+    const { path } = this.props.pageContainer.state;
+    if (pages == null || pages.length === 0) {
+      return (
+        <div className="mt-2">
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('custom_navigation.no_page_list', { path }) }} />
+        </div>
+      );
     }
 
     return (

+ 7 - 10
src/client/js/components/PaginationWrapper.jsx

@@ -5,19 +5,16 @@ import { withTranslation } from 'react-i18next';
 
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '../services/AppContainer';
-
 class PaginationWrapper extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
-      totalItemsCount: 0,
       activePage: 1,
+      totalItemsCount: 0,
       paginationNumbers: {},
-      limit: Infinity,
+      limit: this.props.pagingLimit || Infinity,
     };
 
     this.calculatePagination = this.calculatePagination.bind(this);
@@ -173,7 +170,7 @@ class PaginationWrapper extends React.Component {
 
     return (
       <React.Fragment>
-        <Pagination size="sm" listClassName={this.getListClassName()}>{paginationItems}</Pagination>
+        <Pagination size={this.props.size} listClassName={this.getListClassName()}>{paginationItems}</Pagination>
       </React.Fragment>
     );
   }
@@ -181,19 +178,19 @@ class PaginationWrapper extends React.Component {
 
 }
 
-const PaginationWrappered = withUnstatedContainers(PaginationWrapper, [AppContainer]);
-
 PaginationWrapper.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   activePage: PropTypes.number.isRequired,
   changePage: PropTypes.func.isRequired,
   totalItemsCount: PropTypes.number.isRequired,
   pagingLimit: PropTypes.number.isRequired,
   align: PropTypes.string,
+  size: PropTypes.string,
 };
+
 PaginationWrapper.defaultProps = {
   align: 'left',
+  size: 'md',
 };
 
-export default withTranslation()(PaginationWrappered);
+export default withTranslation()(PaginationWrapper);

+ 11 - 12
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 
-import PaginationWrapper from '../PaginationWrapper';
-
 import Page from '../PageList/Page';
+import PaginationWrapper from '../PaginationWrapper';
 
 class RecentCreated extends React.Component {
 
@@ -17,7 +16,7 @@ class RecentCreated extends React.Component {
       pages: [],
       activePage: 1,
       totalPages: 0,
-      pagingLimit: Infinity,
+      pagingLimit: 10,
     };
 
     this.handlePage = this.handlePage.bind(this);
@@ -32,19 +31,17 @@ class RecentCreated extends React.Component {
     await this.getRecentCreatedList(selectedPage);
   }
 
-  async getRecentCreatedList(selectPageNumber) {
+  async getRecentCreatedList(selectedPage) {
     const { appContainer, userId } = this.props;
-
-    const limit = appContainer.getConfig().recentCreatedLimit;
-    const offset = (selectPageNumber - 1) * limit;
+    const page = selectedPage;
 
     // pagesList get and pagination calculate
-    const res = await appContainer.apiv3Get(`/users/${userId}/recent`, { offset, limit });
-    const { totalCount, pages } = res.data;
+    const res = await appContainer.apiv3Get(`/users/${userId}/recent`, { page });
+    const { totalCount, pages, limit } = res.data;
 
     this.setState({
       pages,
-      activePage: selectPageNumber,
+      activePage: selectedPage,
       totalPages: totalCount,
       pagingLimit: limit,
     });
@@ -59,7 +56,7 @@ class RecentCreated extends React.Component {
    */
   generatePageList(pages) {
     return pages.map(page => (
-      <li key={`recent-created:list-view:${page._id}`}>
+      <li key={`recent-created:list-view:${page._id}`} className="mt-4">
         <Page page={page} />
       </li>
     ));
@@ -70,14 +67,16 @@ class RecentCreated extends React.Component {
 
     return (
       <div className="page-list-container-create">
-        <ul className="page-list-ul page-list-ul-flat mb-3">
+        <ul className="page-list-ul page-list-ul-flat">
           {pageList}
         </ul>
         <PaginationWrapper
+          align="center"
           activePage={this.state.activePage}
           changePage={this.handlePage}
           totalItemsCount={this.state.totalPages}
           pagingLimit={this.state.pagingLimit}
+          size="sm"
         />
       </div>
     );

+ 1 - 24
src/client/js/components/SavePageControls.jsx

@@ -15,7 +15,6 @@ import AppContainer from '../services/AppContainer';
 import EditorContainer from '../services/EditorContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
-import SlackNotification from './SlackNotification';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 const logger = loggerFactory('growi:SavePageControls');
@@ -26,25 +25,14 @@ class SavePageControls extends React.Component {
     super(props);
 
     const config = this.props.appContainer.getConfig();
-    this.hasSlackConfig = config.hasSlackConfig;
     this.isAclEnabled = config.isAclEnabled;
 
-    this.slackEnabledFlagChangedHandler = this.slackEnabledFlagChangedHandler.bind(this);
-    this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
 
     this.save = this.save.bind(this);
     this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
   }
 
-  slackEnabledFlagChangedHandler(isSlackEnabled) {
-    this.props.editorContainer.setState({ isSlackEnabled });
-  }
-
-  slackChannelsChangedHandler(slackChannels) {
-    this.props.editorContainer.setState({ slackChannels });
-  }
-
   updateGrantHandler(data) {
     this.props.editorContainer.setState(data);
   }
@@ -76,6 +64,7 @@ class SavePageControls extends React.Component {
   }
 
   render() {
+
     const { t, pageContainer, editorContainer } = this.props;
 
     const isRootPage = pageContainer.state.path === '/';
@@ -84,18 +73,6 @@ class SavePageControls extends React.Component {
 
     return (
       <div className="d-flex align-items-center form-inline">
-        {this.hasSlackConfig
-          && (
-          <div className="mr-2">
-            <SlackNotification
-              isSlackEnabled={editorContainer.state.isSlackEnabled}
-              slackChannels={editorContainer.state.slackChannels}
-              onEnabledFlagChange={this.slackEnabledFlagChangedHandler}
-              onChannelChange={this.slackChannelsChangedHandler}
-            />
-          </div>
-          )
-        }
 
         {this.isAclEnabled
           && (

+ 1 - 1
src/client/js/components/SearchPage/SearchResult.jsx

@@ -184,7 +184,7 @@ class SearchResult extends React.Component {
       // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
       const pageId = `#id_${page._id}`;
       return (
-        <li key={page._id} className="nav-item page-list-li w-100">
+        <li key={page._id} className="nav-item page-list-li w-100 m-1">
           <a className="nav-link page-list-link d-flex align-items-center" href={pageId}>
             <Page page={page} noLink />
             <div className="ml-auto d-flex">

+ 1 - 1
src/client/js/components/Sidebar/SidebarNav.jsx

@@ -71,7 +71,7 @@ class SidebarNav extends React.Component {
         </div>
         <div className="grw-sidebar-nav-secondary-container">
           {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-          {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href={`/user/${currentUsername}#user-draft-list`} />}
+          {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
           <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
           {isLoggedIn && <SecondaryItem label="Trash" iconName="delete" href="/trash" />}
         </div>

+ 21 - 0
src/client/js/components/SlackLogo.jsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+const SlackLogo = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 448 448"
+    height="20"
+    width="20"
+  >
+    <path
+      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>
+);
+
+export default SlackLogo;

+ 17 - 15
src/client/js/components/SlackNotification.jsx

@@ -2,7 +2,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
-
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -39,22 +38,23 @@ class SlackNotification extends React.Component {
     const { t } = this.props;
 
     return (
-      <div className="grw-slack-notification">
-        <div className="input-group input-group-sm extended-setting">
-          <label className="input-group-addon bg-light">
-            <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
-            <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
-
-            <input
-              type="checkbox"
-              value="1"
-              checked={this.props.isSlackEnabled}
-              onChange={this.updateCheckboxHandler}
-            />
-
+      <div className="grw-slack-notification w-100">
+        <div className="grw-input-group-slack-notification input-group extended-setting">
+          <label className="input-group-addon">
+            <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
+              <input
+                type="checkbox"
+                className="custom-control-input border-0"
+                id={this.props.id}
+                checked={this.props.isSlackEnabled}
+                onChange={this.updateCheckboxHandler}
+              />
+              <label className="custom-control-label align-center" htmlFor={this.props.id}>
+              </label>
+            </div>
           </label>
           <input
-            className="form-control"
+            className="grw-form-control-slack-notification form-control align-top pl-0"
             type="text"
             value={this.props.slackChannels}
             placeholder="Input channels"
@@ -75,10 +75,12 @@ class SlackNotification extends React.Component {
 SlackNotification.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
+  popUp: PropTypes.bool.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   onEnabledFlagChange: PropTypes.func,
   onChannelChange: PropTypes.func,
+  id: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(SlackNotification);

+ 30 - 20
src/client/js/components/TableOfContents.jsx

@@ -8,7 +8,7 @@ import PageContainer from '../services/PageContainer';
 import NavigationContainer from '../services/NavigationContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
-import TopOfTableContents from './TopOfTableContents';
+
 import StickyStretchableScroller from './StickyStretchableScroller';
 
 // eslint-disable-next-line no-unused-vars
@@ -21,15 +21,28 @@ const logger = loggerFactory('growi:TableOfContents');
 const TableOfContents = (props) => {
 
   const { pageContainer, navigationContainer } = props;
+  const { pageUser } = pageContainer.state;
+  const isUserPage = pageUser != null;
 
   const calcViewHeight = useCallback(() => {
     // calculate absolute top of '#revision-toc' element
+    const parentElem = document.querySelector('.grw-side-contents-container');
+    const parentBottom = parentElem.getBoundingClientRect().bottom;
     const containerElem = document.querySelector('#revision-toc');
     const containerTop = containerElem.getBoundingClientRect().top;
+    const containerComputedStyle = getComputedStyle(containerElem);
+    const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
+
+    // get smaller bottom line of window height - .system-version height) and containerTop
+    let bottom = Math.min(window.innerHeight - 20, parentBottom);
 
-    // window height - revisionToc top - .system-version - .grw-fab-container height - top-of-table-contents height
-    return window.innerHeight - containerTop - 20 - 155 - 26;
-  }, []);
+    if (isUserPage) {
+      // raise the bottom line by the height and margin-top of UserContentLinks
+      bottom -= 45;
+    }
+    // bottom - revisionToc top
+    return bottom - (containerTop + containerPaddingTop);
+  }, [isUserPage]);
 
   const { tocHtml } = pageContainer.state;
 
@@ -41,23 +54,20 @@ const TableOfContents = (props) => {
   }, [tocHtml, navigationContainer]);
 
   return (
-    <>
-      <TopOfTableContents />
-      <StickyStretchableScroller
-        contentsElemSelector=".revision-toc .markdownIt-TOC"
-        stickyElemSelector="#revision-toc"
-        calcViewHeightFunc={calcViewHeight}
-      >
-        <div
-          id="revision-toc-content"
-          className="revision-toc-content"
+    <StickyStretchableScroller
+      contentsElemSelector=".revision-toc .markdownIt-TOC"
+      stickyElemSelector=".grw-side-contents-sticky-container"
+      calcViewHeightFunc={calcViewHeight}
+    >
+      <div
+        id="revision-toc-content"
+        className="revision-toc-content"
         // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{
-          __html: tocHtml,
-        }}
-        />
-      </StickyStretchableScroller>
-    </>
+        dangerouslySetInnerHTML={{
+        __html: tocHtml,
+      }}
+      />
+    </StickyStretchableScroller>
   );
 
 };

+ 1 - 0
src/client/js/components/TagsList.jsx

@@ -80,6 +80,7 @@ class TagsList extends React.Component {
             changePage={this.handlePage}
             totalItemsCount={this.state.totalTags}
             pagingLimit={this.state.pagingLimit}
+            size="sm"
           />
         </div>
       </div>

+ 0 - 95
src/client/js/components/TopOfTableContents.jsx

@@ -1,95 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
-
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import RecentChangesIcon from './Icons/RecentChangesIcon';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-
-import PageAccessoriesModal from './PageAccessoriesModal';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-const TopOfTableContents = (props) => {
-  const { pageAccessoriesContainer } = props;
-
-  function renderModal() {
-    return (
-      <>
-        <PageAccessoriesModal
-          isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-          onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-        />
-      </>
-    );
-  }
-
-  return (
-    <>
-      <div className="top-of-table-contents d-flex align-items-end pb-1">
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pagelist')}
-        >
-          <PageListIcon />
-        </button>
-
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('timeline')}
-        >
-          <TimeLineIcon />
-        </button>
-
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
-        >
-          <RecentChangesIcon />
-        </button>
-
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
-        >
-          <AttachmentIcon />
-        </button>
-
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
-        >
-          <ShareLinkIcon />
-        </button>
-
-        <div
-          id="seen-user-list"
-          data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
-          data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
-        >
-        </div>
-      </div>
-      {renderModal()}
-    </>
-  );
-};
-/**
- * Wrapper component for using unstated
- */
-const TopOfTableContentsWrapper = withUnstatedContainers(TopOfTableContents, [PageAccessoriesContainer]);
-
-TopOfTableContents.propTypes = {
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-};
-
-export default withTranslation()(TopOfTableContentsWrapper);

+ 32 - 0
src/client/js/components/TrashPageList.jsx

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

+ 5 - 5
src/client/js/components/User/SeenUserList.jsx → src/client/js/components/User/SeenUserInfo.jsx

@@ -15,12 +15,12 @@ import FootstampIcon from '../FootstampIcon';
 
 /* eslint react/no-multi-comp: 0, react/prop-types: 0 */
 
-const SeenUserList = (props) => {
+const SeenUserInfo = (props) => {
   const [popoverOpen, setPopoverOpen] = useState(false);
   const toggle = () => setPopoverOpen(!popoverOpen);
   const { pageContainer } = props;
   return (
-    <div className="grw-seen-user-list pl-2 ml-2">
+    <div className="grw-seen-user-info">
       <Button id="po-seen-user" color="link" className="px-2">
         <span className="mr-1 footstamp-icon"><FootstampIcon /></span>
         <span className="seen-user-count">{pageContainer.state.countOfSeenUsers}</span>
@@ -36,13 +36,13 @@ const SeenUserList = (props) => {
   );
 };
 
-SeenUserList.propTypes = {
+SeenUserInfo.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const SeenUserListWrapper = withUnstatedContainers(SeenUserList, [PageContainer]);
+const SeenUserInfoWrapper = withUnstatedContainers(SeenUserInfo, [PageContainer]);
 
-export default (SeenUserListWrapper);
+export default (SeenUserInfoWrapper);

+ 41 - 0
src/client/js/components/User/UserInfo.jsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import UserPicture from './UserPicture';
+
+const UserInfo = (props) => {
+  const { pageUser } = props;
+
+  // do not display when the user does not exist
+  if (pageUser == null) {
+    return null;
+  }
+
+  return (
+    <div className="grw-users-info d-flex align-items-center d-edit-none pb-2 border-bottom">
+      <UserPicture user={pageUser} />
+
+      <div className="users-meta">
+        <h1 className="user-page-name">
+          {pageUser.name}
+        </h1>
+        <div className="user-page-meta mt-3 mb-0">
+          <span className="user-page-username mr-4"><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>
+      </div>
+
+    </div>
+  );
+};
+
+
+UserInfo.propTypes = {
+  pageUser: PropTypes.object,
+};
+
+export default UserInfo;

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

@@ -0,0 +1,54 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import NavigationContainer from '../services/NavigationContainer';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:cli:UserContentsLinks');
+const WIKI_HEADER_LINK = 120;
+
+/**
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ */
+const UserContentsLinks = (props) => {
+
+  const { navigationContainer } = props;
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+  return (
+    <div className="mt-3 d-flex justify-content-around">
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm"
+        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="mr-2 icon-star"></i>
+        <span>Bookmarks</span>
+      </button>
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm"
+        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    </div>
+  );
+
+};
+
+UserContentsLinks.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(UserContentsLinks, [NavigationContainer]);

+ 12 - 102
src/client/js/legacy/crowi.js

@@ -15,6 +15,9 @@ window.Crowi = Crowi;
  * @param {number} line
  */
 Crowi.setCaretLineData = function(line) {
+  const { appContainer } = window;
+  const navigationContainer = appContainer.getContainer('NavigationContainer');
+  navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
 };
@@ -152,15 +155,11 @@ Crowi.highlightSelectedSection = function(hash) {
 };
 
 $(() => {
-  const appContainer = window.appContainer;
-  const config = appContainer.getConfig();
-
   const pageId = $('#content-main').data('page-id');
   // const revisionId = $('#content-main').data('page-revision-id');
   // const revisionCreatedAt = $('#content-main').data('page-revision-created');
   // const currentUser = $('#content-main').data('current-user');
   const isSeen = $('#content-main').data('page-is-seen');
-  const isSavedStatesOfTabChanges = config.isSavedStatesOfTabChanges;
 
   $('[data-toggle="popover"]').popover();
   $('[data-toggle="tooltip"]').tooltip();
@@ -198,110 +197,23 @@ $(() => {
       });
     }
   } // end if pageId
-
-  // tab changing handling
-  $('a[href="#revision-body"]').on('show.bs.tab', () => {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-    navigationContainer.setEditorMode(null);
-  });
-  $('a[href="#edit"]').on('show.bs.tab', () => {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-    navigationContainer.setEditorMode('builtin');
-    $('body').addClass('on-edit');
-    $('body').addClass('builtin-editor');
-  });
-  $('a[href="#edit"]').on('hide.bs.tab', () => {
-    $('body').removeClass('on-edit');
-    $('body').removeClass('builtin-editor');
-  });
-  $('a[href="#hackmd"]').on('show.bs.tab', () => {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-    navigationContainer.setEditorMode('hackmd');
-    $('body').addClass('on-edit');
-    $('body').addClass('hackmd');
-  });
-
-  $('a[href="#hackmd"]').on('hide.bs.tab', () => {
-    $('body').removeClass('on-edit');
-    $('body').removeClass('hackmd');
-  });
-
-  // hash handling
-  if (isSavedStatesOfTabChanges) {
-    $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
-      window.location.hash = '#revision-history';
-      window.history.replaceState('', 'History', '#revision-history');
-    });
-    $('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', () => {
-      window.location.hash = '#edit';
-      window.history.replaceState('', 'Edit', '#edit');
-    });
-    $('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', () => {
-      window.location.hash = '#hackmd';
-      window.history.replaceState('', 'HackMD', '#hackmd');
-    });
-    $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', () => {
-      // couln't solve https://github.com/weseek/crowi-plus/issues/119 completely -- 2017.07.03 Yuki Takei
-      window.location.hash = '#';
-      window.history.replaceState('', '', window.location.href);
-    });
-  }
-  else {
-    $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
-      window.history.replaceState('', 'History', '#revision-history');
-    });
-    $('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', () => {
-      window.history.replaceState('', 'Edit', '#edit');
-    });
-    $('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', () => {
-      window.history.replaceState('', 'HackMD', '#hackmd');
-    });
-    $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', () => {
-      window.history.replaceState('', '', window.location.href.replace(window.location.hash, ''));
-    });
-    // replace all href="#edit" link behaviors
-    $(document).on('click', 'a[href="#edit"]', () => {
-      window.location.replace('#edit');
-    });
-  }
-
-  // focus to editor when 'shown.bs.tab' event fired
-  $('a[href="#edit"]').on('shown.bs.tab', (e) => {
-    Crowi.setCaretLineAndFocusToEditor();
-  });
 });
 
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
 
-  // do nothing if user is guest
-  if (appContainer.currentUser == null) {
-    return;
-  }
-
   // hash on page
   if (window.location.hash) {
     const navigationContainer = appContainer.getContainer('NavigationContainer');
 
-    if ((window.location.hash === '#edit' || window.location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
-      navigationContainer.setEditorMode('builtin');
-
-      $('a[data-toggle="tab"][href="#edit"]').tab('show');
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
+    if (window.location.hash === '#edit') {
+      navigationContainer.setEditorMode('edit');
 
       // focus
       Crowi.setCaretLineAndFocusToEditor();
     }
-    else if (window.location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
+    else if (window.location.hash === '#hackmd') {
       navigationContainer.setEditorMode('hackmd');
-
-      $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-    }
-    else if (window.location.hash === '#revision-history' && $('.tab-pane#revision-history').length > 0) {
-      $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
 });
@@ -353,22 +265,20 @@ window.addEventListener('hashchange', (e) => {
   Crowi.unhighlightSelectedSection(Crowi.findHashFromUrl(e.oldURL));
   Crowi.highlightSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
+  const { appContainer } = window;
+  const navigationContainer = appContainer.getContainer('NavigationContainer');
+
 
   // hash on page
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
-      $('a[data-toggle="tab"][href="#edit"]').tab('show');
+      navigationContainer.setEditorMode('edit');
+      Crowi.setCaretLineAndFocusToEditor();
     }
     else if (window.location.hash === '#hackmd') {
-      $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
-    }
-    else if (window.location.hash === '#revision-history') {
-      $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
+      navigationContainer.setEditorMode('hackmd');
     }
   }
-  else {
-    $('a[data-toggle="tab"][href="#revision-body"]').tab('show');
-  }
 });
 
 // adjust min-height of page for print temporarily

+ 105 - 42
src/client/js/services/AdminAppContainer.js

@@ -20,23 +20,39 @@ export default class AdminAppContainer extends Container {
       confidential: '',
       globalLang: '',
       fileUpload: '',
+
       siteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
       isMailerSetup: false,
       fromAddress: '',
       transmissionMethod: '',
+
       smtpHost: '',
       smtpPort: '',
       smtpUser: '',
       smtpPassword: '',
       sesAccessKeyId: '',
       sesSecretAccessKey: '',
-      region: '',
-      customEndpoint: '',
-      bucket: '',
-      accessKeyId: '',
-      secretAccessKey: '',
+
+      fileUploadType: '',
+      envFileUploadType: '',
+      isFixedFileUploadByEnvVar: false,
+
+      gcsUseOnlyEnvVars: false,
+      gcsApiKeyJsonPath: '',
+      envGcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      envGcsBucket: '',
+      gcsUploadNamespace: '',
+      envGcsUploadNamespace: '',
+
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+
       isEnabledPlugins: true,
     };
 
@@ -73,13 +89,33 @@ export default class AdminAppContainer extends Container {
       smtpPassword: appSettingsParams.smtpPassword,
       sesAccessKeyId: appSettingsParams.sesAccessKeyId,
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
-      region: appSettingsParams.region,
-      customEndpoint: appSettingsParams.customEndpoint,
-      bucket: appSettingsParams.bucket,
-      accessKeyId: appSettingsParams.accessKeyId,
-      secretAccessKey: appSettingsParams.secretAccessKey,
+
+      fileUploadType: appSettingsParams.fileUploadType,
+      envFileUploadType: appSettingsParams.envFileUploadType,
+      useOnlyEnvVarForFileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType,
+
+      s3Region: appSettingsParams.s3Region,
+      s3CustomEndpoint: appSettingsParams.s3CustomEndpoint,
+      s3Bucket: appSettingsParams.s3Bucket,
+      s3AccessKeyId: appSettingsParams.s3AccessKeyId,
+      s3SecretAccessKey: appSettingsParams.s3SecretAccessKey,
+      gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
+      gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath,
+      gcsBucket: appSettingsParams.gcsBucket,
+      gcsUploadNamespace: appSettingsParams.gcsUploadNamespace,
+      envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+      envGcsBucket: appSettingsParams.envGcsBucket,
+      envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
     });
+
+    // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
+    // and if env var 'FILE_UPLOAD' is null, envFileUploadType is 'aws' that is default value of 'FILE_UPLOAD'.
+    if (appSettingsParams.useOnlyEnvVarForFileUploadType) {
+      this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
+      this.setState({ isFixedFileUploadByEnvVar: true });
+    }
+
   }
 
   /**
@@ -161,52 +197,66 @@ export default class AdminAppContainer extends Container {
   }
 
   /**
-   * Change sesAccessKeyId
+   * Change s3Region
+   */
+  changeS3Region(s3Region) {
+    this.setState({ s3Region });
+  }
+
+  /**
+   * Change s3CustomEndpoint
    */
-  changeSesAccessKeyId(sesAccessKeyId) {
-    this.setState({ sesAccessKeyId });
+  changeS3CustomEndpoint(s3CustomEndpoint) {
+    this.setState({ s3CustomEndpoint });
   }
 
   /**
-   * Change sesSecretAccessKey
+   * Change fileUploadType
    */
-  changeSesSecretAccessKey(sesSecretAccessKey) {
-    this.setState({ sesSecretAccessKey });
+  changeFileUploadType(fileUploadType) {
+    this.setState({ fileUploadType });
   }
 
   /**
    * Change region
    */
-  changeRegion(region) {
-    this.setState({ region });
+  changeS3Bucket(s3Bucket) {
+    this.setState({ s3Bucket });
   }
 
   /**
-   * Change custom endpoint
+   * Change access key id
    */
-  changeCustomEndpoint(customEndpoint) {
-    this.setState({ customEndpoint });
+  changeS3AccessKeyId(s3AccessKeyId) {
+    this.setState({ s3AccessKeyId });
   }
 
   /**
-   * Change bucket name
+   * Change secret access key
    */
-  changeBucket(bucket) {
-    this.setState({ bucket });
+  changeS3SecretAccessKey(s3SecretAccessKey) {
+    this.setState({ s3SecretAccessKey });
   }
 
   /**
-   * Change access key id
+   * Change gcsApiKeyJsonPath
    */
-  changeAccessKeyId(accessKeyId) {
-    this.setState({ accessKeyId });
+  changeGcsApiKeyJsonPath(gcsApiKeyJsonPath) {
+    this.setState({ gcsApiKeyJsonPath });
   }
 
   /**
-   * Change secret access key
+   * Change gcsBucket
+   */
+  changeGcsBucket(gcsBucket) {
+    this.setState({ gcsBucket });
+  }
+
+  /**
+   * Change gcsUploadNamespace
    */
-  changeSecretAccessKey(secretAccessKey) {
-    this.setState({ secretAccessKey });
+  changeGcsUploadNamespace(gcsUploadNamespace) {
+    this.setState({ gcsUploadNamespace });
   }
 
   /**
@@ -303,20 +353,33 @@ export default class AdminAppContainer extends Container {
   }
 
   /**
-   * Update AWS setting
+   * Update updateFileUploadSettingHandler
    * @memberOf AdminAppContainer
-   * @return {Array} Appearance
    */
-  async updateAwsSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/aws-setting', {
-      region: this.state.region,
-      customEndpoint: this.state.customEndpoint,
-      bucket: this.state.bucket,
-      accessKeyId: this.state.accessKeyId,
-      secretAccessKey: this.state.secretAccessKey,
-    });
-    const { awsSettingParams } = response.data;
-    return awsSettingParams;
+  async updateFileUploadSettingHandler() {
+    const { fileUploadType } = this.state;
+
+    const requestParams = {
+      fileUploadType,
+    };
+
+    if (fileUploadType === 'gcs') {
+      requestParams.gcsApiKeyJsonPath = this.state.gcsApiKeyJsonPath;
+      requestParams.gcsBucket = this.state.gcsBucket;
+      requestParams.gcsUploadNamespace = this.state.gcsUploadNamespace;
+    }
+
+    if (fileUploadType === 'aws') {
+      requestParams.s3Region = this.state.s3Region;
+      requestParams.s3CustomEndpoint = this.state.s3CustomEndpoint;
+      requestParams.s3Bucket = this.state.s3Bucket;
+      requestParams.s3AccessKeyId = this.state.s3AccessKeyId;
+      requestParams.s3SecretAccessKey = this.state.s3SecretAccessKey;
+    }
+
+    const response = await this.appContainer.apiv3.put('/app-settings/file-upload-setting', requestParams);
+    const { responseParams } = response.data;
+    return this.setState(responseParams);
   }
 
   /**

+ 47 - 7
src/client/js/services/AdminCustomizeContainer.js

@@ -27,7 +27,12 @@ export default class AdminCustomizeContainer extends Container {
       isEnabledTimeline: false,
       isSavedStatesOfTabChanges: false,
       isEnabledAttachTitleHeader: false,
-      currentRecentCreatedLimit: 10,
+
+      pageLimitationS: null,
+      pageLimitationM: null,
+      pageLimitationL: null,
+      pageLimitationXL: null,
+
       isEnabledStaleNotification: false,
       isAllReplyShown: false,
       currentHighlightJsStyleId: '',
@@ -51,6 +56,10 @@ export default class AdminCustomizeContainer extends Container {
       },
       /* eslint-enable quote-props, no-multi-spaces */
     };
+    this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
+    this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
+    this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
+    this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
 
   }
 
@@ -74,7 +83,10 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledTimeline: customizeParams.isEnabledTimeline,
         isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
-        currentRecentCreatedLimit: customizeParams.recentCreatedLimit,
+        pageLimitationS: customizeParams.pageLimitationS,
+        pageLimitationM: customizeParams.pageLimitationM,
+        pageLimitationL: customizeParams.pageLimitationL,
+        pageLimitationXL: customizeParams.pageLimitationXL,
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
         isAllReplyShown: customizeParams.isAllReplyShown,
         currentHighlightJsStyleId: customizeParams.styleName,
@@ -128,11 +140,33 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ isEnabledAttachTitleHeader:  !this.state.isEnabledAttachTitleHeader });
   }
 
+
+  /**
+   * S: Switch pageListLimitationS
+   */
+  switchPageListLimitationS(value) {
+    this.setState({ pageLimitationS: value });
+  }
+
+  /**
+   * M: Switch pageListLimitationM
+   */
+  switchPageListLimitationM(value) {
+    this.setState({ pageLimitationM: value });
+  }
+
+  /**
+   * L: Switch pageListLimitationL
+   */
+  switchPageListLimitationL(value) {
+    this.setState({ pageLimitationL: value });
+  }
+
   /**
-   * Switch recentCreatedLimit
+   * XL: Switch pageListLimitationXL
    */
-  switchRecentCreatedLimit(value) {
-    this.setState({ currentRecentCreatedLimit: value });
+  switchPageListLimitationXL(value) {
+    this.setState({ pageLimitationXL: value });
   }
 
   /**
@@ -255,7 +289,10 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledTimeline: this.state.isEnabledTimeline,
         isSavedStatesOfTabChanges: this.state.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
-        recentCreatedLimit: this.state.currentRecentCreatedLimit,
+        pageLimitationS: this.state.pageLimitationS,
+        pageLimitationM: this.state.pageLimitationM,
+        pageLimitationL: this.state.pageLimitationL,
+        pageLimitationXL: this.state.pageLimitationXL,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isAllReplyShown: this.state.isAllReplyShown,
       });
@@ -264,7 +301,10 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledTimeline: customizedParams.isEnabledTimeline,
         isSavedStatesOfTabChanges: customizedParams.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: customizedParams.isEnabledAttachTitleHeader,
-        recentCreatedLimit: customizedParams.currentRecentCreatedLimit,
+        pageLimitationS: customizedParams.pageLimitationS,
+        pageLimitationM: customizedParams.pageLimitationM,
+        pageLimitationL: customizedParams.pageLimitationL,
+        pageLimitationXL: customizedParams.pageLimitationXL,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isAllReplyShown: customizedParams.isAllReplyShown,
       });

+ 6 - 0
src/client/js/services/AdminSocketIoContainer.js

@@ -1,4 +1,5 @@
 import SocketIoContainer from './SocketIoContainer';
+import { toastError } from '../util/apiNotification';
 
 /**
  * A subclass of SocketIoContainer for /admin namespace
@@ -7,6 +8,11 @@ export default class AdminSocketIoContainer extends SocketIoContainer {
 
   constructor(appContainer) {
     super(appContainer, '/admin');
+
+    // show toastr
+    this.socket.on('error', (error) => {
+      toastError(new Error(error));
+    });
   }
 
   /**

+ 33 - 2
src/client/js/services/NavigationContainer.js

@@ -1,4 +1,7 @@
 import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:NavigationContainer');
 
 /**
  * Service container related to options for Application
@@ -19,7 +22,7 @@ export default class NavigationContainer extends Container {
     const { localStorage } = window;
 
     this.state = {
-      editorMode: null,
+      editorMode: 'view',
 
       isDeviceSmallerThanMd: null,
       preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
@@ -37,6 +40,7 @@ export default class NavigationContainer extends Container {
 
     this.openPageCreateModal = this.openPageCreateModal.bind(this);
     this.closePageCreateModal = this.closePageCreateModal.bind(this);
+    this.setEditorMode = this.setEditorMode.bind(this);
     this.initDeviceSize();
     this.initScrollEvent();
   }
@@ -85,7 +89,34 @@ export default class NavigationContainer extends Container {
   }
 
   setEditorMode(editorMode) {
+
+    if (this.appContainer.currentUser == null) {
+      logger.warn('Please login or signup to edit the page or use hackmd.');
+      return;
+    }
+
     this.setState({ editorMode });
+    if (editorMode === 'view') {
+      $('body').removeClass('on-edit');
+      $('body').removeClass('builtin-editor');
+      $('body').removeClass('hackmd');
+      window.history.replaceState(null, '', window.location.pathname);
+    }
+
+    if (editorMode === 'edit') {
+      $('body').addClass('on-edit');
+      $('body').addClass('builtin-editor');
+      window.location.hash = '#edit';
+    }
+
+    if (editorMode === 'hackmd') {
+      $('body').addClass('on-edit');
+      $('body').addClass('hackmd');
+      $('body').removeClass('builtin-editor');
+      window.location.hash = '#hackmd';
+
+    }
+
     this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
   }
 
@@ -136,7 +167,7 @@ export default class NavigationContainer extends Container {
     } = newState;
 
     // get preference on view or edit
-    const preferDrawerMode = editorMode != null ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
+    const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
 
     const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
     const isDrawerOpened = false; // close Drawer anyway

+ 50 - 19
src/client/js/services/PageContainer.js

@@ -4,6 +4,7 @@ import loggerFactory from '@alias/logger';
 
 import * as entities from 'entities';
 import * as toastr from 'toastr';
+import { isTrashPage } from '@commons/util/path-utils';
 import { toastError } from '../util/apiNotification';
 
 import {
@@ -39,6 +40,7 @@ export default class PageContainer extends Container {
 
     const revisionId = mainContent.getAttribute('data-page-revision-id');
     const path = decodeURI(mainContent.getAttribute('data-path'));
+
     this.state = {
       // local page data
       markdown: null, // will be initialized after initStateMarkdown()
@@ -47,26 +49,28 @@ export default class PageContainer extends Container {
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
       tocHtml: '',
-      isLiked: JSON.parse(mainContent.getAttribute('data-page-is-liked')),
-
-      seenUserIds: mainContent.getAttribute('data-page-ids-of-seen-users'),
+      isLiked: false,
+      isBookmarked: false,
       seenUsers: [],
+      seenUserIds: mainContent.getAttribute('data-page-ids-of-seen-users'),
       countOfSeenUsers: mainContent.getAttribute('data-page-count-of-seen-users'),
 
       likerUsers: [],
       sumOfLikers: 0,
-
+      sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
-      isForbidden:  JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
-      isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
-      isDeletable:  JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
-      isAbleToDeleteCompletely:  JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
+      isTrashPage: isTrashPage(path),
+      isForbidden: JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
+      isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
+      isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
+      isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
+      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       tags: null,
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
-      shareLinksNumber:  mainContent.getAttribute('data-share-links-number'),
+      shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
       shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
 
       // latest(on remote) information
@@ -153,20 +157,43 @@ export default class PageContainer extends Container {
 
   async initStateOthers() {
 
-    const likerListElem = document.getElementById('liker-list');
-    if (likerListElem != null) {
-      const { userIdsStr, sumOfLikers } = likerListElem.dataset;
-      this.setState({ sumOfLikers });
+    this.retrieveLikeInfo();
+    this.retrieveBookmarkInfo();
+    this.checkAndUpdateImageUrlCached(this.state.likerUsers);
+  }
 
-      if (userIdsStr === '') {
-        return;
-      }
+  async retrieveLikeInfo() {
+    const like = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
+    this.setState({
+      sumOfLikers: like.data.sumOfLikers,
+      likerUsers: like.data.users.liker,
+      isLiked: like.data.isLiked,
+    });
+  }
+
+  async toggleLike() {
+    const bool = !this.state.isLiked;
+    await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool });
+    this.setState({ isLiked: bool });
 
-      const { users } = await this.appContainer.apiGet('/users.list', { user_ids: userIdsStr });
-      this.setState({ likerUsers: users });
+    return this.retrieveLikeInfo();
+  }
 
-      this.checkAndUpdateImageUrlCached(users);
+  async retrieveBookmarkInfo() {
+    const response = await this.appContainer.apiv3Get('/bookmarks', { pageId: this.state.pageId });
+    if (response.data.bookmarks != null) {
+      this.setState({ isBookmarked: true });
+    }
+    else {
+      this.setState({ isBookmarked: false });
     }
+    this.setState({ sumOfBookmarks: response.data.sumOfBookmarks });
+  }
+
+  async toggleBookmark() {
+    const bool = !this.state.isBookmarked;
+    await this.appContainer.apiv3Put('/bookmarks', { pageId: this.state.pageId, bool });
+    return this.retrieveBookmarkInfo();
   }
 
   async checkAndUpdateImageUrlCached(users) {
@@ -494,4 +521,8 @@ export default class PageContainer extends Container {
 
   }
 
+  /* TODO GW-325 */
+  retrieveMyBookmarkList() {
+  }
+
 }

+ 6 - 4
src/client/js/services/PageHistoryContainer.js

@@ -17,7 +17,6 @@ export default class PageHistoryContainer extends Container {
 
     this.appContainer = appContainer;
     this.pageContainer = pageContainer;
-
     this.dummyRevisions = 0;
 
     this.state = {
@@ -29,7 +28,7 @@ export default class PageHistoryContainer extends Container {
 
       totalPages: 0,
       activePage: 1,
-      pagingLimit: Infinity,
+      pagingLimit: null,
     };
 
     this.retrieveRevisions = this.retrieveRevisions.bind(this);
@@ -51,13 +50,16 @@ export default class PageHistoryContainer extends Container {
    */
   async retrieveRevisions(selectedPage) {
     const { pageId, shareLinkId } = this.pageContainer.state;
+    const page = selectedPage;
+
     if (!pageId) {
       return;
     }
 
-    const res = await this.appContainer.apiv3Get('/revisions/list', { pageId, shareLinkId, selectedPage });
+    const res = await this.appContainer.apiv3Get('/revisions/list', {
+      pageId, shareLinkId, page,
+    });
     const rev = res.data.docs;
-
     // set Pagination state
     this.setState({
       activePage: selectedPage,

+ 11 - 0
src/client/js/services/SocketIoContainer.js

@@ -2,6 +2,10 @@ import { Container } from 'unstated';
 
 import io from 'socket.io-client';
 
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:cli:SocketIoContainer');
+
 /**
  * Service container related to options for WebSocket
  * @extends {Container} unstated Container
@@ -21,6 +25,13 @@ export default class SocketIoContainer extends Container {
     });
     this.socketClientId = Math.floor(Math.random() * 100000);
 
+    this.socket.on('connect_error', (error) => {
+      logger.error(error);
+    });
+    this.socket.on('error', (error) => {
+      logger.error(error);
+    });
+
     this.state = {
     };
 

+ 2 - 1
src/client/styles/scss/_admin.scss

@@ -1,6 +1,7 @@
 .admin-page {
   .title {
-    padding: 0.5rem 15px;
+    padding-top: 1rem;
+    padding-bottom: 1rem;
 
     line-height: 1em;
 

+ 4 - 0
src/client/styles/scss/_attachments.scss

@@ -48,3 +48,7 @@
     }
   }
 }
+
+.attachment-userpicture .picture {
+  vertical-align: text-bottom;
+}

+ 5 - 0
src/client/styles/scss/_comment.scss

@@ -40,6 +40,11 @@
       font-size: 0.9em;
       color: $gray-400;
     }
+
+    .page-comment-revision svg {
+      width: 16px;
+      height: 16px;
+    }
   }
 
   .page-comment-main {

+ 1 - 11
src/client/styles/scss/_comment_growi.scss

@@ -32,11 +32,6 @@
     }
   }
 
-  .page-comments-row {
-    margin: 30px 0px;
-    border-top: 5px solid;
-  }
-
   .page-comments {
     h4 {
       margin-bottom: 1em;
@@ -98,13 +93,8 @@
   }
   // reply button
   .btn.btn-comment-reply {
-    width: 120px;
     margin-top: 0.5em;
-    margin-right: 15px;
-
-    border-top: none;
-    border-right: none;
-    border-left: none;
+    border: none;
   }
 
   // display cheatsheet for comment form only

+ 3 - 9
src/client/styles/scss/_draft.scss

@@ -1,14 +1,5 @@
 .draft-list-item {
   .panel-heading {
-    .caret {
-      transition: 0.4s;
-      transform: rotate(-90deg);
-
-      &.caret-opened {
-        transform: rotate(0deg);
-      }
-    }
-
     .icon-container {
       a:hover {
         text-decoration: unset;
@@ -30,4 +21,7 @@
   .draft-copy {
     cursor: pointer;
   }
+  .draft-path {
+    cursor: pointer;
+  }
 }

+ 1 - 4
src/client/styles/scss/_handsontable.scss

@@ -8,10 +8,7 @@
   }
 }
 
-// expanded window layout
-.handsontable-modal.handsontable-modal-expanded {
-  @include expand-modal-fullscreen(true, true);
-
+.handsontable-modal.grw-modal-expanded {
   // expand .hot-table-container (with flexbox)
   .hot-table-container {
     flex: 1;

+ 13 - 0
src/client/styles/scss/_layout.scss

@@ -30,6 +30,19 @@ body {
 
 .main {
   margin-top: 1rem;
+
+  @include media-breakpoint-up(md) {
+    margin-top: 3rem;
+  }
+}
+
+.grw-side-contents-sticky-container {
+  position: sticky;
+  // growisubnavigation + grw-navbar-boder
+  top: calc(100px + 4px);
+  width: 250px;
+  min-width: 250px;
+  margin-top: 5px;
 }
 
 .grw-fab {

+ 0 - 37
src/client/styles/scss/_layout_growi.scss

@@ -1,37 +0,0 @@
-@import 'layout';
-
-.growi {
-  .content-main {
-    padding: 0;
-  }
-
-  .top-of-table-contents {
-    line-height: 1.25;
-    border-bottom: 1px solid transparent;
-
-    .user-list-content {
-      direction: rtl;
-
-      .liker-user-count,
-      .seen-user-count {
-        font-size: 12px;
-        font-weight: bolder;
-      }
-    }
-    .cls-1 {
-      isolation: isolate;
-    }
-  }
-
-  .revision-toc {
-    position: sticky;
-    // growisubnavigation + grw-navbar-boder
-    top: calc(100px + 4px);
-    min-width: 100%;
-    margin-top: 5px;
-
-    .revision-toc-content {
-      padding: 0;
-    }
-  }
-}

+ 80 - 7
src/client/styles/scss/_mixins.scss

@@ -15,9 +15,8 @@
   }
 }
 
-@mixin expand-editor($editor-header-plus-footer, $navbar-height-adjustment: 0px) {
-  $navbar-height: $grw-navbar-border-width + $navbar-height-adjustment;
-  $header-plus-footer: $navbar-height + $editor-header-plus-footer + 2px; // add .main padding-top
+@mixin expand-editor($editor-margin-top) {
+  $header-plus-footer: $editor-margin-top + $grw-editor-navbar-bottom-height;
 
   $editor-margin: $header-plus-footer //
     + 25px //   add .btn-open-dropzone height
@@ -25,10 +24,15 @@
 
   .main {
     width: 100%;
-    height: calc(100vh - #{$navbar-height});
-    padding-top: 2px;
+    height: calc(100vh - #{$editor-margin-top});
     margin-top: 0px !important;
 
+    .container-lg {
+      max-width: unset;
+      padding: 0;
+      margin: 0;
+    }
+
     &,
     .content-main,
     .tab-content {
@@ -36,8 +40,7 @@
       flex: 1;
       flex-direction: column;
 
-      .tab-pane#edit,
-      .tab-pane#hackmd {
+      .tab-pane {
         height: calc(100vh - #{$header-plus-footer});
         min-height: calc(100vh - #{$header-plus-footer}); // for IE11
       }
@@ -223,3 +226,73 @@
   transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
   transition-duration: 300ms;
 }
+
+@mixin border-vertical($beforeOrAfter, $borderColor, $borderLength, $zIndex: initial, $isBtnGroup: false) {
+  position: relative;
+  @if $isBtnGroup {
+    &:not(:first-child) {
+      margin-left: 0;
+      border-left: none;
+    }
+    &:not(:last-child) {
+      border-right: none;
+    }
+  }
+  &:not(:first-child) {
+    &::#{$beforeOrAfter} {
+      position: absolute;
+      top: calc((100% - #{$borderLength}) / 2);
+      left: 0;
+      z-index: $zIndex;
+      width: 100%;
+      height: $borderLength;
+      margin-left: -0.5px;
+      content: '';
+      border-left: 1px solid $borderColor;
+      transition: border-color 0.15s ease-in-out;
+    }
+  }
+}
+
+@mixin three-stranded-button($textColor, $borderColor, $bgColorHoverAndActive, $bgColor: white) {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 70px;
+  padding-right: 0;
+  padding-left: 0;
+  color: $textColor;
+  white-space: nowrap;
+  background-color: $bgColor;
+  border-color: $borderColor;
+
+  @include border-vertical('before', $borderColor, 70%, 1, true);
+
+  &.view-button,
+  &.edit-button {
+    .grw-three-stranded-button-icon {
+      margin-right: -0.25rem;
+    }
+  }
+  &.hackmd-button {
+    font-size: 12px;
+    letter-spacing: -0.6px;
+
+    .grw-three-stranded-button-icon {
+      margin-right: -0.1rem;
+    }
+  }
+  &:hover,
+  &:active,
+  &.active {
+    color: $textColor;
+    background-color: $bgColorHoverAndActive;
+    border-color: $borderColor;
+    &::after {
+      border-color: $bgColorHoverAndActive;
+    }
+  }
+  &:not(:disabled):not(.disabled):focus {
+    box-shadow: none;
+  }
+}

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