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

Merge branch 'master' into imprv/improve-link-edit-modal

yusuketk 5 лет назад
Родитель
Сommit
84ec69a8f9
100 измененных файлов с 1650 добавлено и 1278 удалено
  1. 7 12
      .devcontainer/docker-compose.yml
  2. 15 0
      .github/workflows/release-rc.yml
  3. 24 7
      .github/workflows/release.yml
  4. 15 0
      CHANGES.md
  5. 2 2
      bin/github-actions/update-readme.sh
  6. 5 5
      docker/README.md
  7. 6 5
      resource/locales/en_US/admin/admin.json
  8. 4 1
      resource/locales/en_US/translation.json
  9. 2 1
      resource/locales/ja_JP/admin/admin.json
  10. 4 1
      resource/locales/ja_JP/translation.json
  11. 6 5
      resource/locales/zh_CN/admin/admin.json
  12. 5 2
      resource/locales/zh_CN/translation.json
  13. 5 2
      src/client/js/app.jsx
  14. 2 1
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  15. 1 1
      src/client/js/components/Admin/Customize/ThemeColorBox.jsx
  16. 18 10
      src/client/js/components/Admin/ManageExternalAccount.jsx
  17. 53 3
      src/client/js/components/Admin/Notification/NotificationSetting.jsx
  18. 0 98
      src/client/js/components/Admin/Notification/NotificationSettingContents.jsx
  19. 123 166
      src/client/js/components/Admin/Security/SecurityManagementContents.jsx
  20. 1 1
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  21. 1 1
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  22. 2 2
      src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  23. 11 7
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  24. 12 9
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  25. 1 1
      src/client/js/components/Admin/UserManagement.jsx
  26. 3 3
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  27. 1 1
      src/client/js/components/Admin/Users/UserMenu.jsx
  28. 40 27
      src/client/js/components/BookmarkButton.jsx
  29. 0 156
      src/client/js/components/CustomNavigation.jsx
  30. 231 0
      src/client/js/components/CustomNavigation/CustomNav.jsx
  31. 52 0
      src/client/js/components/CustomNavigation/CustomNavAndContents.jsx
  32. 37 0
      src/client/js/components/CustomNavigation/CustomTabContent.jsx
  33. 18 0
      src/client/js/components/Drawio.jsx
  34. 12 4
      src/client/js/components/Fab.jsx
  35. 54 0
      src/client/js/components/ForbiddenPage.jsx
  36. 10 2
      src/client/js/components/Hotkeys/Subscribers/EditPage.jsx
  37. 35 22
      src/client/js/components/LikeButton.jsx
  38. 2 4
      src/client/js/components/Me/ApiSettings.jsx
  39. 7 9
      src/client/js/components/Me/ExternalAccountLinkedMe.jsx
  40. 4 6
      src/client/js/components/Me/PasswordSettings.jsx
  41. 16 33
      src/client/js/components/Me/PersonalSettings.jsx
  42. 2 3
      src/client/js/components/Me/ProfileImageSettings.jsx
  43. 6 8
      src/client/js/components/Me/UserSettings.jsx
  44. 4 4
      src/client/js/components/MyDraftList/Draft.jsx
  45. 2 4
      src/client/js/components/MyDraftList/MyDraftList.jsx
  46. 1 1
      src/client/js/components/Navbar/DrawerToggler.jsx
  47. 12 3
      src/client/js/components/Navbar/GrowiNavbar.jsx
  48. 16 3
      src/client/js/components/Navbar/GrowiNavbarBottom.jsx
  49. 33 41
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  50. 110 0
      src/client/js/components/Navbar/PageEditorModeManager.jsx
  51. 0 73
      src/client/js/components/Navbar/ThreeStrandedButton.jsx
  52. 20 17
      src/client/js/components/NotFoundPage.jsx
  53. 1 1
      src/client/js/components/Page.jsx
  54. 22 13
      src/client/js/components/Page/CopyDropdown.jsx
  55. 32 10
      src/client/js/components/Page/NotFoundAlert.jsx
  56. 2 2
      src/client/js/components/Page/PageManagement.jsx
  57. 23 26
      src/client/js/components/Page/RenderTagLabels.jsx
  58. 0 5
      src/client/js/components/Page/RevisionPathControls.jsx
  59. 1 1
      src/client/js/components/Page/ShareLinkAlert.jsx
  60. 3 0
      src/client/js/components/Page/TagLabels.jsx
  61. 4 5
      src/client/js/components/Page/TrashPageAlert.jsx
  62. 4 8
      src/client/js/components/PageAccessories.jsx
  63. 33 19
      src/client/js/components/PageAccessoriesModal.jsx
  64. 60 53
      src/client/js/components/PageAccessoriesModalControl.jsx
  65. 1 1
      src/client/js/components/PageAttachment.jsx
  66. 22 20
      src/client/js/components/PageComment/CommentEditor.jsx
  67. 1 1
      src/client/js/components/PageComments.jsx
  68. 3 3
      src/client/js/components/PageContentFooter.jsx
  69. 4 4
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  70. 5 5
      src/client/js/components/PageEditor/OptionsSelector.jsx
  71. 2 2
      src/client/js/components/PageList.jsx
  72. 55 76
      src/client/js/components/PaginationWrapper.jsx
  73. 1 1
      src/client/js/components/RecentCreated/RecentCreated.jsx
  74. 1 1
      src/client/js/components/SavePageControls.jsx
  75. 1 1
      src/client/js/components/SearchPage/SearchResult.jsx
  76. 1 1
      src/client/js/components/ShareLink/ShareLinkForm.jsx
  77. 1 1
      src/client/js/components/Sidebar/SidebarNav.jsx
  78. 21 9
      src/client/js/components/TableOfContents.jsx
  79. 13 11
      src/client/js/components/TrashPageList.jsx
  80. 3 2
      src/client/js/components/User/SeenUserInfo.jsx
  81. 1 1
      src/client/js/components/User/UserInfo.jsx
  82. 9 46
      src/client/js/legacy/crowi.js
  83. 6 3
      src/client/js/services/AppContainer.js
  84. 4 0
      src/client/js/services/NavigationContainer.js
  85. 151 46
      src/client/js/services/PageContainer.js
  86. 1 1
      src/client/js/services/PageHistoryContainer.js
  87. 7 3
      src/client/js/services/TagContainer.js
  88. 3 3
      src/client/styles/scss/_admin.scss
  89. 5 11
      src/client/styles/scss/_layout.scss
  90. 0 4
      src/client/styles/scss/_me.scss
  91. 3 46
      src/client/styles/scss/_mixins.scss
  92. 11 8
      src/client/styles/scss/_navbar.scss
  93. 29 18
      src/client/styles/scss/_on-edit.scss
  94. 28 0
      src/client/styles/scss/_override-bootstrap-variables.scss
  95. 2 2
      src/client/styles/scss/_page-accessories-control.scss
  96. 1 26
      src/client/styles/scss/_page.scss
  97. 0 10
      src/client/styles/scss/_page_list.scss
  98. 1 0
      src/client/styles/scss/_search.scss
  99. 5 1
      src/client/styles/scss/_subnav.scss
  100. 10 0
      src/client/styles/scss/_user.scss

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

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

+ 15 - 0
.github/workflows/release-rc.yml

@@ -45,6 +45,21 @@ jobs:
         semver: ${{ env.SEMVER }}
         publish: true
 
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Docker Tags by SemVer in Github Container Registry
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+      with:
+        source: growi
+        target: ghcr.io/weseek/growi
+        semver: ${{ env.SEMVER }}
+        publish: true
+
     - name: Check whether workspace is clean
       run: |
         STATUS=`git status --porcelain`

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

@@ -97,13 +97,6 @@ jobs:
         additional-tags: 'latest'
         publish: true
 
-    - name: Slack Notification
-      uses: weseek/ghaction-release-slack-notification@master
-      with:
-        channel: '#general'
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-        created_tag: 'v${{ needs.github-release.outputs.RELEASE_VERSION }}${{ env.SUFFIX }}'
-
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v2
       with:
@@ -112,6 +105,30 @@ jobs:
         repository: weseek/growi
         readme-filepath: ./docker/README.md
 
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Docker Tags by SemVer in Github Container Registry
+      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+      with:
+        source: growi${{ env.SUFFIX }}
+        target: ghcr.io/weseek/growi
+        semver: ${{ needs.github-release.outputs.RELEASE_VERSION }}
+        suffix: ${{ env.SUFFIX }}
+        additional-tags: 'latest'
+        publish: true
+
+    - name: Slack Notification
+      uses: weseek/ghaction-release-slack-notification@master
+      with:
+        channel: '#general'
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+        created_tag: 'v${{ needs.github-release.outputs.RELEASE_VERSION }}${{ env.SUFFIX }}'
+
     - name: Check whether workspace is clean
       run: |
         STATUS=`git status --porcelain`

+ 15 - 0
CHANGES.md

@@ -15,6 +15,21 @@
     * migrate-mongo
     * mongoose
 
+## v4.1.12
+
+* Fix: Adjust line-height for pre under li
+* Fix: Emptying trash process is broken
+
+## v4.1.11
+
+* Improvement: Generating draft DOM id strategy
+* Fix: GROWI version downgrade causes a validation error for user.lang
+
+## v4.1.10
+
+* Fix: Make listing users API secure
+* Fix: Error message when the server denies guest user connecting with socket.io
+
 ## v4.1.9
 
 * Feature: Environment variables to set max connection size to deliver push messages to all clients

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

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

+ 5 - 5
docker/README.md

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

+ 6 - 5
resource/locales/en_US/admin/admin.json

@@ -116,19 +116,19 @@
       "tab_switch_desc1": "Save edit tab and history tab switching in the browser and make it object for forward/back command of the browser.",
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "attach_title_header": "Add h1 section when create new page automatically",
-      "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
+      "attach_title_header_desc": "Add page path to the first line as h1 section when create new page.",
 
       "list_num_s": "Number of list displayed on modals",
-      "list_num_desc_s": "Set number of list per page such as 'Pagelist', 'Timeline', 'Page History' and 'Share Link' pages",
+      "list_num_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
 
       "list_num_m": "Number of list displayed on article pages included other contents",
-      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages",
+      "list_num_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_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",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
 
 
 
@@ -275,6 +275,7 @@
     "external_accounts":"External accounts",
     "create_external_account":"Create external account",
     "external_account_list": "External Account List",
+    "external_account_none":"No External Account",
     "invite": "Invite",
     "invited": "User was invited",
     "back_to_user_management": "Back to User Management",

+ 4 - 1
resource/locales/en_US/translation.json

@@ -99,7 +99,6 @@
   "Input page name (optional)": "Input page name (optional)",
   "New Page": "New page",
   "Create under": "Create page under below:",
-  "Table of Contents": "Table of Contents",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
   "Site URL settings": "Site URL settings",
@@ -191,6 +190,7 @@
     }
   },
   "page_me_apitoken": {
+    "api_token": "API Token",
     "notice": {
       "apitoken_issued": "API token is not issued.",
       "update_token1": "You can update to generate a new API token.",
@@ -295,6 +295,9 @@
       "no_deadline":"This page has no expiration date"
     }
   },
+  "page_table_of_contents": {
+    "empty": "Table of Contents is empty"
+  },
   "page_edit": {
     "Show active line": "Show active line",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",

+ 2 - 1
resource/locales/ja_JP/admin/admin.json

@@ -119,7 +119,7 @@
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
 
       "list_num_s": "モーダルに表示されるリスト数",
-      "list_num_desc_s": "モーダルにおける <Pagelist> <Timeline> <Page History> <Share Link>での、1ページあたりの表示数を設定します。",
+      "list_num_desc_s": "モーダルにおける <ページリスト> <タイムライン> <更新履歴> <添付ファイル>での、1ページあたりの表示数を設定します。",
 
       "list_num_m": "ユーザーページに表示されるリスト数",
       "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
@@ -273,6 +273,7 @@
     "external_accounts": "外部アカウント",
     "create_external_account":"外部アカウントの作成",
     "external_account_list": "外部アカウント一覧",
+    "external_account_none":"外部アカウントはありません",
     "invite": "招待する",
     "invited": "ユーザーを招待しました",
     "back_to_user_management": "ユーザー管理に戻る",

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

@@ -100,7 +100,6 @@
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",
   "Create under": "ページを以下に作成",
-  "Table of Contents": "目次",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
   "Site URL settings": "サイトURL設定",
@@ -194,6 +193,7 @@
     }
   },
   "page_me_apitoken": {
+    "api_token": "API Token",
     "notice": {
       "apitoken_issued": "API Token が設定されていません。",
       "update_token1": "API Token を更新すると、自動的に新しい Token が生成されます。",
@@ -297,6 +297,9 @@
       "no_deadline": "このページに有効期限は設定されていません。"
     }
   },
+  "page_table_of_contents": {
+    "empty": "目次は空です"
+  },
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",

+ 6 - 5
resource/locales/zh_CN/admin/admin.json

@@ -129,16 +129,16 @@
       "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_desc_s": "Set number of list per page such as 'Page List', 'Timeline', 'Page History' and 'Attachment' pages.",
 
       "list_num_m": "Number of list displayed on article pages included other contents",
-      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages",
+      "list_num_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_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",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages.",
 
 			"stale_notification": "在过期页上显示通知",
 			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
@@ -282,7 +282,8 @@
 		"external_account": "外部账户管理",
 		"external_accounts": "外部账户",
 		"create_external_account": "创建外部账户",
-		"external_account_list": "外部账户列表",
+    "external_account_list": "外部账户列表",
+    "external_account_none":"No External Account",
 		"invite": "邀请",
 		"invited": "已邀请用户",
 		"back_to_user_management": "返回用户管理",

+ 5 - 2
resource/locales/zh_CN/translation.json

@@ -108,7 +108,6 @@
 	"Input page name (optional)": "Input page name (optional)",
 	"New Page": "新页面",
 	"Create under": "Create page under below:",
-	"Table of Contents": "Table of Contents",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
 	"Site URL settings": "主页URL设置",
@@ -193,6 +192,7 @@
 		}
 	},
 	"page_me_apitoken": {
+    "api_token": "API Token",
 		"notice": {
 			"apitoken_issued": "API token 未发布。",
 			"update_token1": "您可以更新以生成新的API令牌。",
@@ -282,7 +282,10 @@
 		"notice": {
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
-	},
+  },
+  "page_table_of_contents": {
+    "empty": "目录为空"
+  },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
   },

+ 5 - 2
src/client/js/app.jsx

@@ -21,6 +21,7 @@ import TrashPageList from './components/TrashPageList';
 import TrashPageAlert from './components/Page/TrashPageAlert';
 import NotFoundPage from './components/NotFoundPage';
 import NotFoundAlert from './components/Page/NotFoundAlert';
+import ForbiddenPage from './components/ForbiddenPage';
 import PageStatusAlert from './components/PageStatusAlert';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from './components/Icons/RecentlyCreatedIcon';
@@ -86,12 +87,14 @@ Object.assign(componentMappings, {
   '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}
+    isGuestUserMode={appContainer.isGuestUser}
+    isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
 
+  'forbidden-page': <ForbiddenPage />,
+
   'page-timeline': <PageTimeline />,
 
   'personal-setting': <PersonalSettings crowi={personalContainer} />,

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

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

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

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

+ 18 - 10
src/client/js/components/Admin/ManageExternalAccount.jsx

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

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

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

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

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

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

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

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

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

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

@@ -24,7 +24,7 @@ const Pager = (props) => {
       changePage={props.handlePage}
       totalItemsCount={props.totalLinks}
       pagingLimit={props.limit}
-      align="right"
+      align="center"
       size="sm"
     />
   );

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

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

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

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

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

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

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

@@ -120,7 +120,7 @@ class UserManagement extends React.Component {
           changePage={this.handlePage}
           totalItemsCount={adminUsersContainer.state.totalUsers}
           pagingLimit={adminUsersContainer.state.pagingLimit}
-          align="right"
+          align="center"
           size="sm"
         />
       </div>

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

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

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

@@ -80,7 +80,7 @@ class UserMenu extends React.Component {
 
     return (
       <Fragment>
-        <div className="btn-group admin-user-menu" role="group">
+        <div className="btn-group admin-user-menu position-absolute" role="group">
           <button id="userMenu" type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
             <i className="icon-settings"></i>
           </button>

+ 40 - 27
src/client/js/components/BookmarkButton.jsx

@@ -1,9 +1,13 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { toastError } from '../util/apiNotification';
+import { UncontrolledTooltip } from 'reactstrap';
+import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
+
+import { toastError } from '../util/apiNotification';
 import PageContainer from '../services/PageContainer';
+import AppContainer from '../services/AppContainer';
 
 class BookmarkButton extends React.Component {
 
@@ -14,7 +18,12 @@ class BookmarkButton extends React.Component {
   }
 
   async handleClick() {
-    const { pageContainer } = this.props;
+    const { appContainer, pageContainer } = this.props;
+    const { isGuestUser } = appContainer;
+
+    if (isGuestUser) {
+      return;
+    }
 
     try {
       pageContainer.toggleBookmark();
@@ -24,44 +33,48 @@ class BookmarkButton extends React.Component {
     }
   }
 
-  isUserLoggedIn() {
-    return this.props.crowi.currentUserId != null;
-  }
 
   render() {
-    const { pageContainer } = this.props;
-    // if guest user
-    if (!this.isUserLoggedIn()) {
-      return <div></div>;
-    }
+    const { appContainer, pageContainer, t } = this.props;
+    const { isGuestUser } = appContainer;
 
     return (
-      <button
-        type="button"
-        href="#"
-        title="Bookmark"
-        onClick={this.handleClick}
-        className={`btn btn-bookmark border-0
-          ${`btn-${this.props.size}`}
-          ${pageContainer.state.isBookmarked ? 'active' : ''}`}
-      >
-        <i className="icon-star mr-3"></i>
-        <span className="total-bookmarks">
-          {pageContainer.state.sumOfBookmarks}
-        </span>
-      </button>
+      <div>
+        <button
+          type="button"
+          id="bookmark-button"
+          onClick={this.handleClick}
+          className={`btn btn-bookmark border-0
+          ${`btn-${this.props.size}`} ${pageContainer.state.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+        >
+          <i className="icon-star mr-3"></i>
+          <span className="total-bookmarks">
+            {pageContainer.state.sumOfBookmarks}
+          </span>
+        </button>
+
+        {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+        )}
+      </div>
     );
   }
 
 }
 
-const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [PageContainer]);
+/**
+ * Wrapper component for using unstated
+ */
+const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer, PageContainer]);
 
 BookmarkButton.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   pageId: PropTypes.string,
-  crowi: PropTypes.object.isRequired,
+  t: PropTypes.func.isRequired,
   size: PropTypes.string,
 };
 
@@ -69,4 +82,4 @@ BookmarkButton.defaultProps = {
   size: 'md',
 };
 
-export default BookmarkButtonWrapper;
+export default withTranslation()(BookmarkButtonWrapper);

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

@@ -1,156 +0,0 @@
-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;

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,6 +1,9 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
+import NavigationContainer from '../../../services/NavigationContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 const EditPage = (props) => {
 
   // setup effect
@@ -10,6 +13,8 @@ const EditPage = (props) => {
       return;
     }
 
+    props.navigationContainer.setEditorMode('edit');
+
     // remove this
     props.onDeleteRender(this);
   }, [props]);
@@ -18,11 +23,14 @@ const EditPage = (props) => {
 };
 
 EditPage.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-EditPage.getHotkeyStrokes = () => {
+const EditPageWrapper = withUnstatedContainers(EditPage, [NavigationContainer]);
+
+EditPageWrapper.getHotkeyStrokes = () => {
   return [['e']];
 };
 
-export default EditPage;
+export default EditPageWrapper;

+ 35 - 22
src/client/js/components/LikeButton.jsx

@@ -1,8 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { toastError } from '../util/apiNotification';
+import { UncontrolledTooltip } from 'reactstrap';
+import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
+
+import { toastError } from '../util/apiNotification';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 
@@ -15,7 +18,13 @@ class LikeButton extends React.Component {
   }
 
   async handleClick() {
-    const { pageContainer } = this.props;
+    const { appContainer, pageContainer } = this.props;
+    const { isGuestUser } = appContainer;
+
+    if (isGuestUser) {
+      return;
+    }
+
     try {
       pageContainer.toggleLike();
     }
@@ -24,29 +33,32 @@ class LikeButton extends React.Component {
     }
   }
 
-  isUserLoggedIn() {
-    return this.props.appContainer.currentUserId != null;
-  }
 
   render() {
-    const { pageContainer } = this.props;
-    // if guest user
-    if (!this.isUserLoggedIn()) {
-      return <div></div>;
-    }
+    const { appContainer, pageContainer, t } = this.props;
+    const { isGuestUser } = appContainer;
 
     return (
-      <button
-        type="button"
-        onClick={this.handleClick}
-        className={`btn btn-like border-0 d-edit-none
-        ${pageContainer.state.isLiked ? 'active' : ''}`}
-      >
-        <i className="icon-like mr-3"></i>
-        <span className="total-likes">
-          {pageContainer.state.sumOfLikers}
-        </span>
-      </button>
+      <div>
+        <button
+          type="button"
+          id="like-button"
+          onClick={this.handleClick}
+          className={`btn btn-like border-0
+          ${pageContainer.state.isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+        >
+          <i className="icon-like mr-3"></i>
+          <span className="total-likes">
+            {pageContainer.state.sumOfLikers}
+          </span>
+        </button>
+
+        {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+        )}
+      </div>
     );
   }
 
@@ -61,7 +73,8 @@ LikeButton.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
+  t: PropTypes.func.isRequired,
   size: PropTypes.string,
 };
 
-export default LikeButtonWrapper;
+export default withTranslation()(LikeButtonWrapper);

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

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

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

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

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

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

+ 16 - 33
src/client/js/components/Me/PersonalSettings.jsx

@@ -1,70 +1,53 @@
 
-import React from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import CustomNavigation from '../CustomNavigation';
+
+import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import UserSettings from './UserSettings';
 import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import ApiSettings from './ApiSettings';
 
-class PersonalSettings extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    const UserIcon = () => {
-      return <i className="icon-fw icon-user"></i>;
-    };
-
-    const shereAltIcon = () => {
-      return <i className="icon-fw icon-share-alt"></i>;
-    };
+const PersonalSettings = (props) => {
 
-    const lockIcon = () => {
-      return <i className="icon-fw icon-lock"></i>;
-    };
+  const { t } = props;
 
-    const paperPlaneIcon = () => {
-      return <i className="icon-fw icon-paper-plane"></i>;
-    };
-
-    const navTabMapping = {
+  const navTabMapping = useMemo(() => {
+    return {
       user_infomation: {
-        Icon: UserIcon,
+        Icon: () => <i className="icon-fw icon-user"></i>,
         Content: UserSettings,
         i18n: t('User Information'),
         index: 0,
       },
       external_accounts: {
-        Icon: shereAltIcon,
+        Icon: () => <i className="icon-fw icon-share-alt"></i>,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
         index: 1,
       },
       password_settings: {
-        Icon: lockIcon,
+        Icon: () => <i className="icon-fw icon-lock"></i>,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
         index: 2,
       },
       api_settings: {
-        Icon: paperPlaneIcon,
+        Icon: () => <i className="icon-fw icon-paper-plane"></i>,
         Content: ApiSettings,
         i18n: t('API Settings'),
         index: 3,
       },
     };
+  }, [t]);
 
 
-    return (
-      <>
-        <CustomNavigation navTabMapping={navTabMapping} />
-      </>
-    );
-  }
+  return (
+    <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+  );
 
-}
+};
 
 PersonalSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next

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

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

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

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

+ 4 - 4
src/client/js/components/MyDraftList/Draft.jsx

@@ -105,10 +105,9 @@ class Draft extends React.Component {
   }
 
   renderControls() {
-    const { t, path } = this.props;
+    const { t, path, index } = this.props;
 
-    const encodedPath = path.replace(/\//g, '-');
-    const tooltipTargetId = `draft-copied-tooltip_${encodedPath}`;
+    const tooltipTargetId = `draft-copied-tooltip_${index}`;
 
     return (
       <div className="icon-container">
@@ -116,7 +115,7 @@ class Draft extends React.Component {
           ? null
           : (
             <a
-              href={`${this.props.path}#edit`}
+              href={`${path}#edit`}
               target="_blank"
               rel="noopener noreferrer"
               data-toggle="tooltip"
@@ -203,6 +202,7 @@ Draft.propTypes = {
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
+  index: PropTypes.number.isRequired,
   path: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   isExist: PropTypes.bool.isRequired,

+ 2 - 4
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -22,7 +22,6 @@ class MyDraftList extends React.Component {
       currentDrafts: [],
       activePage: 1,
       totalDrafts: 0,
-      // [TODO: rename pageLimitationM to pageLimitationL]
       pagingLimit: Infinity,
     };
 
@@ -91,9 +90,10 @@ class MyDraftList extends React.Component {
    *
    */
   generateDraftList(drafts) {
-    return drafts.map((draft) => {
+    return drafts.map((draft, index) => {
       return (
         <Draft
+          index={index}
           key={draft.path}
           path={draft.path}
           markdown={draft.markdown}
@@ -135,8 +135,6 @@ class MyDraftList extends React.Component {
 
     return (
       <div className="page-list-container-create ">
-        <h1>My Drafts</h1>
-        <hr />
         { totalCount === 0
           && <span className="mt-2">No drafts yet.</span>
         }

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

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

+ 12 - 3
src/client/js/components/Navbar/GrowiNavbar.jsx

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

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

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

+ 33 - 41
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -3,8 +3,6 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
-import { isTrashPage } from '@commons/util/path-utils';
-
 import DevidedPagePath from '@commons/models/devided-page-path';
 import LinkedPagePath from '@commons/models/linked-page-path';
 import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
@@ -18,7 +16,7 @@ import RevisionPathControls from '../Page/RevisionPathControls';
 import TagLabels from '../Page/TagLabels';
 import LikeButton from '../LikeButton';
 import BookmarkButton from '../BookmarkButton';
-import ThreeStrandedButton from './ThreeStrandedButton';
+import PageEditorModeManager from './PageEditorModeManager';
 
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
@@ -26,20 +24,22 @@ import DrawerToggler from './DrawerToggler';
 import PageManagement from '../Page/PageManagement';
 
 
-// eslint-disable-next-line react/prop-types
-const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
+const PagePathNav = ({
+  // eslint-disable-next-line react/prop-types
+  pageId, pagePath, isEditorMode,
+}) => {
 
   const dPagePath = new DevidedPagePath(pagePath, false, true);
 
   let formerLink;
   let latterLink;
 
-  // when the path is root or first level
-  if (dPagePath.isRoot || dPagePath.isFormerRoot) {
+  // one line
+  if (dPagePath.isRoot || dPagePath.isFormerRoot || isEditorMode) {
     const linkedPagePath = new LinkedPagePath(pagePath);
     latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
   }
-  // when the path is second level or deeper
+  // two line
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -56,7 +56,6 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
           <RevisionPathControls
             pageId={pageId}
             pagePath={pagePath}
-            isPageForbidden={isPageForbidden}
           />
         </div>
       </span>
@@ -70,19 +69,15 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
 /* eslint-disable react/prop-types */
 const PageReactionButtons = ({ appContainer, pageContainer }) => {
 
-  const {
-    pageId, isLiked, pageUser,
-  } = pageContainer.state;
-
   return (
     <>
-      {pageUser == null && (
-      <span className="mr-2">
-        <LikeButton pageId={pageId} isLiked={isLiked} />
-      </span>
+      {pageContainer.isAbleToShowLikeButton && (
+        <span className="mr-2">
+          <LikeButton />
+        </span>
       )}
       <span>
-        <BookmarkButton pageId={pageId} crowi={appContainer} />
+        <BookmarkButton />
       </span>
     </>
   );
@@ -93,21 +88,17 @@ const GrowiSubNavigation = (props) => {
   const {
     appContainer, navigationContainer, pageContainer, isCompactMode,
   } = props;
-  const { isDrawerMode, editorMode } = navigationContainer.state;
+  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
-    pageId, path, createdAt, creator, updatedAt, revisionAuthor,
-    isForbidden: isPageForbidden, pageUser, isNotCreatable, shareLinkId,
+    pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
 
-  const { currentUser } = appContainer;
-  const isPageNotFound = pageId == null;
+  const { isGuestUser } = appContainer;
+  const isEditorMode = editorMode !== 'view';
   // 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;
+  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
 
-  function onThreeStrandedButtonClicked(viewType) {
+  function onPageEditorModeButtonClicked(viewType) {
     navigationContainer.setEditorMode(viewType);
   }
 
@@ -117,42 +108,43 @@ const GrowiSubNavigation = (props) => {
       {/* Left side */}
       <div className="d-flex grw-subnav-left-side">
         { isDrawerMode && (
-          <div className="d-none d-md-flex align-items-center border-right mr-3 pr-3">
+          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
             <DrawerToggler />
           </div>
         ) }
 
         <div className="grw-path-nav-container">
-          { !isCompactMode && !isTagLabelHidden && !isPageForbidden && !isUserPage && !isSharedPage && (
-            <div className="mb-2">
+          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
+            <div className="grw-taglabels-container">
               <TagLabels editorMode={editorMode} />
             </div>
           ) }
-          <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
+          <PagePathNav pageId={pageId} pagePath={path} isEditorMode={isEditorMode} />
         </div>
       </div>
 
       {/* Right side */}
       <div className="d-flex">
 
-        <div className="d-flex flex-column align-items-end">
+        <div className={`d-flex ${isEditorMode ? 'align-items-center' : 'flex-column align-items-end'}`}>
           <div className="d-flex">
-            { !isPageInTrash && !isPageNotFound && !isPageForbidden && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
-            { !isPageNotFound && !isPageForbidden && <PageManagement isCompactMode={isCompactMode} /> }
+            { pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
+            { pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} /> }
           </div>
-          <div className="mt-2">
-            {!isNotCreatable && !isPageInTrash && !isPageForbidden && (
-              <ThreeStrandedButton
-                onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}
-                isBtnDisabled={currentUser == null}
+          <div className={`${isEditorMode ? 'ml-2' : 'mt-2'}`}>
+            {pageContainer.isAbleToShowPageEditorModeManager && (
+              <PageEditorModeManager
+                onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+                isBtnDisabled={isGuestUser}
                 editorMode={editorMode}
+                isDeviceSmallerThanMd={isDeviceSmallerThanMd}
               />
             )}
           </div>
         </div>
 
         {/* Page Authors */}
-        { (!isCompactMode && !isUserPage && !isPageNotFound && !isPageForbidden) && (
+        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
           <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
             <li className="pb-1">
               <AuthorInfo user={creator} date={createdAt} locate="subnav" />

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

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

+ 0 - 73
src/client/js/components/Navbar/ThreeStrandedButton.jsx

@@ -1,73 +0,0 @@
-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;
-
-
-  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"
-        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>
-      )}
-    </>
-  );
-
-};
-
-ThreeStrandedButton.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  onThreeStrandedButtonClicked: PropTypes.func,
-  isBtnDisabled: PropTypes.bool,
-  editorMode: PropTypes.string,
-};
-
-ThreeStrandedButton.defaultProps = {
-  isBtnDisabled: false,
-};
-
-export default withTranslation()(ThreeStrandedButton);

+ 20 - 17
src/client/js/components/NotFoundPage.jsx

@@ -1,33 +1,36 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import CustomNavigation from './CustomNavigation';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 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,
-    },
-  };
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: PageList,
+        i18n: t('page_list'),
+        index: 0,
+      },
+      timeLine: {
+        Icon: TimeLineIcon,
+        Content: PageTimeline,
+        i18n: t('Timeline View'),
+        index: 1,
+      },
+    };
+  }, [t]);
+
 
   return (
     <div className="mt-5 d-edit-none">
-      <CustomNavigation navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
   );
 };

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

@@ -134,7 +134,7 @@ class Page extends React.Component {
     const { markdown } = pageContainer.state;
 
     return (
-      <div className={`${isMobile && 'page-mobile'}`}>
+      <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
 
         { isLoggedIn && (

+ 22 - 13
src/client/js/components/Page/CopyDropdown.jsx

@@ -16,25 +16,24 @@ class CopyDropdown extends React.Component {
     super(props);
 
     this.state = {
-      dropdownOpen: false,
       tooltipOpen: false,
       isParamsAppended: true,
+      pagePathWithParams: '',
+      pagePathUrl: '',
+      permalink: '',
+      markdownLink: '',
     };
 
     this.id = (Math.random() * 1000).toString();
 
-    this.toggle = this.toggle.bind(this);
     this.showToolTip = this.showToolTip.bind(this);
+    this.generateItemContents = this.generateItemContents.bind(this);
     this.generatePagePathWithParams = this.generatePagePathWithParams.bind(this);
     this.generatePagePathUrl = this.generatePagePathUrl.bind(this);
     this.generatePermalink = this.generatePermalink.bind(this);
     this.generateMarkdownLink = this.generateMarkdownLink.bind(this);
   }
 
-  toggle() {
-    this.setState({ dropdownOpen: !this.state.dropdownOpen });
-  }
-
   showToolTip() {
     this.setState({ tooltipOpen: true });
     setTimeout(() => {
@@ -64,6 +63,17 @@ class CopyDropdown extends React.Component {
     return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
   }
 
+  generateItemContents() {
+    const pagePathWithParams = this.generatePagePathWithParams();
+    const pagePathUrl = this.generatePagePathUrl();
+    const permalink = this.generatePermalink();
+    const markdownLink = this.generateMarkdownLink();
+
+    this.setState({
+      pagePathWithParams, pagePathUrl, permalink, markdownLink,
+    });
+  }
+
   generatePagePathWithParams() {
     const { pagePath } = this.props;
     return decodeURI(`${pagePath}${this.uriParams}`);
@@ -108,11 +118,9 @@ class CopyDropdown extends React.Component {
     const {
       t, pageId, isShareLinkMode,
     } = this.props;
-    const { isParamsAppended } = this.state;
-
-    const pagePathWithParams = this.generatePagePathWithParams();
-    const pagePathUrl = this.generatePagePathUrl();
-    const permalink = this.generatePermalink();
+    const {
+      isParamsAppended, pagePathWithParams, pagePathUrl, permalink, markdownLink,
+    } = this.state;
 
     const copyTarget = isShareLinkMode ? `copyShareLink${pageId}` : 'copyPagePathDropdown';
     const dropdownToggleStyle = isShareLinkMode ? 'btn btn-secondary' : 'd-block text-muted bg-transparent btn-copy border-0';
@@ -128,6 +136,7 @@ class CopyDropdown extends React.Component {
             caret
             className={dropdownToggleStyle}
             style={this.props.buttonStyle}
+            onClick={this.generateItemContents}
           >
             { isShareLinkMode ? (
               <>Copy Link</>
@@ -195,9 +204,9 @@ class CopyDropdown extends React.Component {
 
             {/* Markdown Link */}
             { pageId && (
-              <CopyToClipboard text={this.generateMarkdownLink()} onCopy={this.showToolTip}>
+              <CopyToClipboard text={markdownLink} onCopy={this.showToolTip}>
                 <DropdownItem className="px-3 text-wrap">
-                  <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={this.generateMarkdownLink()} isContentsWrap />
+                  <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={markdownLink} isContentsWrap />
                 </DropdownItem>
               </CopyToClipboard>
             )}

+ 32 - 10
src/client/js/components/Page/NotFoundAlert.jsx

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

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

@@ -148,8 +148,8 @@ const PageManagement = (props) => {
     return (
       <>
         <div className="dropdown-divider"></div>
-        <button className="dropdown-item" type="button" onClick={openPageDeleteModalHandler}>
-          <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+        <button className="dropdown-item text-danger" type="button" onClick={openPageDeleteModalHandler}>
+          <i className="icon-fw icon-fire"></i> { t('Delete') }
         </button>
       </>
     );

+ 23 - 26
src/client/js/components/Page/RenderTagLabels.jsx

@@ -2,12 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import PageContainer from '../../services/PageContainer';
+import { UncontrolledTooltip } from 'reactstrap';
 
-function RenderTagLabels(props) {
-  const { t, tags, pageContainer } = props;
-  const { pageId } = pageContainer;
+const RenderTagLabels = React.memo((props) => {
+  const {
+    t, tags, pageId, isGuestUser,
+  } = props;
 
   function openEditorHandler() {
     if (props.openEditorModal == null) {
@@ -35,35 +35,32 @@ function RenderTagLabels(props) {
     <>
       {tagElements}
 
-      <a className={`btn btn-link btn-edit-tags p-0 text-muted ${isTagsEmpty ? 'no-tags' : ''}`} onClick={openEditorHandler}>
-        { isTagsEmpty
-          ? (
-            <>{ t('Add tags for this page') }<i className="ml-1 icon-plus"></i></>
-          )
-          : (
-            <i className="icon-plus"></i>
-          )
-        }
-      </a>
+      <div id="edit-tags-btn-wrapper-for-tooltip">
+        <a
+          className={`btn btn-link btn-edit-tags p-0 text-muted ${isTagsEmpty ? 'no-tags' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          onClick={openEditorHandler}
+        >
+          { isTagsEmpty && <>{ t('Add tags for this page') }</>}
+          <i className="ml-1 icon-plus"></i>
+        </a>
+      </div>
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="edit-tags-btn-wrapper-for-tooltip" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
     </>
   );
 
-}
-
-/**
- * Wrapper component for using unstated
- */
-const RenderTagLabelsWrapper = withUnstatedContainers(RenderTagLabels, [PageContainer]);
-
+});
 
 RenderTagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
   tags: PropTypes.array,
   openEditorModal: PropTypes.func,
-
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  isGuestUser: PropTypes.bool.isRequired,
+  pageId: PropTypes.string.isRequired,
 };
 
-export default withTranslation()(RenderTagLabelsWrapper);
+export default withTranslation()(RenderTagLabels);

+ 0 - 5
src/client/js/components/Page/RevisionPathControls.jsx

@@ -29,11 +29,6 @@ RevisionPathControls.propTypes = {
 
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
-  isPageForbidden: PropTypes.bool,
-};
-
-RevisionPathControls.defaultProps = {
-  isPageForbidden: false,
 };
 
 export default withTranslation()(RevisionPathControls);

+ 1 - 1
src/client/js/components/Page/ShareLinkAlert.jsx

@@ -41,7 +41,7 @@ const ShareLinkAlert = (props) => {
   }
 
   return (
-    <p className={`alert alert-${specifyColor()} py-3 px-4`}>
+    <p className={`alert alert-${specifyColor()} py-3 px-4 d-edit-none`}>
       <i className="icon-fw icon-link"></i>
       {(expiredAt === '' ? <span>{t('page_page.notice.no_deadline')}</span>
       // eslint-disable-next-line react/no-danger

+ 3 - 0
src/client/js/components/Page/TagLabels.jsx

@@ -74,6 +74,7 @@ class TagLabels extends React.Component {
 
   render() {
     const tags = this.getTagData();
+    const { appContainer, pageContainer } = this.props;
 
     return (
       <>
@@ -84,6 +85,8 @@ class TagLabels extends React.Component {
             <RenderTagLabels
               tags={tags}
               openEditorModal={this.openEditorModal}
+              pageId={pageContainer.state.pageId}
+              isGuestUser={appContainer.isGuestUser}
             />
           </Suspense>
         </form>

+ 4 - 5
src/client/js/components/Page/TrashPageAlert.jsx

@@ -13,11 +13,10 @@ import PageDeleteModal from '../PageDeleteModal';
 
 
 const TrashPageAlert = (props) => {
-  const { t, appContainer, pageContainer } = props;
+  const { t, pageContainer } = props;
   const {
-    path, isDeleted, lastUpdateUsername, updatedAt, hasChildren, isAbleToDeleteCompletely,
+    path, isDeleted, lastUpdateUsername, updatedAt, isAbleToDeleteCompletely,
   } = pageContainer.state;
-  const { currentUser } = appContainer;
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
   const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
@@ -113,8 +112,8 @@ const TrashPageAlert = (props) => {
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           {isDeleted && <span><br /><UserPicture user={{ username: lastUpdateUsername }} /> Deleted by {lastUpdateUsername} at {updatedAt}</span>}
         </div>
-        {(currentUser.admin && path === '/trash' && hasChildren) && renderEmptyButton()}
-        {(isDeleted && currentUser != null) && renderTrashPageManagementButtons()}
+        { pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}
+        { pageContainer.isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
       </div>
       {renderModals()}
     </>

+ 4 - 8
src/client/js/components/PageAccessories.jsx

@@ -10,18 +10,14 @@ 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;
-  }
+  const { isGuestUser, isSharedUser } = appContainer;
 
   return (
     <>
-      <PageAccessoriesModalControl isGuestUserMode={isGuestUserMode} />
+      <PageAccessoriesModalControl isGuestUser={isGuestUser} isSharedUser={isSharedUser} />
       <PageAccessoriesModal
-        isGuestUserMode={isGuestUserMode}
+        isGuestUser={isGuestUser}
+        isSharedUser={isSharedUser}
         isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
         onClose={pageAccessoriesContainer.closePageAccessoriesModal}
       />

+ 33 - 19
src/client/js/components/PageAccessoriesModal.jsx

@@ -19,12 +19,12 @@ import PageTimeline from './PageTimeline';
 import PageList from './PageList';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
-import { CustomNav } from './CustomNavigation';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
 import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
   const {
-    t, pageAccessoriesContainer, onClose, isGuestUserMode,
+    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
   } = props;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
@@ -36,16 +36,19 @@ const PageAccessoriesModal = (props) => {
         Icon: PageListIcon,
         i18n: t('page_list'),
         index: 0,
+        isLinkEnabled: v => !isSharedUser,
       },
-      timeline:  {
+      timeline: {
         Icon: TimeLineIcon,
         i18n: t('Timeline View'),
         index: 1,
+        isLinkEnabled: v => !isSharedUser,
       },
       pageHistory: {
         Icon: HistoryIcon,
         i18n: t('History'),
         index: 2,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
       attachment: {
         Icon: AttachmentIcon,
@@ -56,10 +59,10 @@ const PageAccessoriesModal = (props) => {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUserMode,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
     };
-  }, [t, isGuestUserMode]);
+  }, [t, isGuestUser, isSharedUser]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {
@@ -77,24 +80,34 @@ const PageAccessoriesModal = (props) => {
   };
 
   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>
+    <div className="d-flex flex-nowrap">
       <ExpandOrContractButton
         isWindowExpanded={isWindowExpanded}
         expandWindow={expandWindow}
         contractWindow={contractWindow}
       />
-    </span>
+      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
   );
 
   return (
     <React.Fragment>
-      <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className={`grw-page-accessories-modal ${isWindowExpanded && 'grw-modal-expanded'} `}>
+      <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} />
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={switchActiveTab}
+            breakpointToHideInactiveTabsDown="md"
+            hideBorderBottom
+          />
         </ModalHeader>
         <ModalBody className="overflow-auto grw-modal-body-style p-0">
           {/* Do not use CustomTabContent because of performance problem:
@@ -106,15 +119,15 @@ const PageAccessoriesModal = (props) => {
             <TabPane tabId="timeline">
               {activeComponents.has('timeline') && <PageTimeline /> }
             </TabPane>
-            <TabPane tabId="pageHistory">
-              <div className="overflow-auto">
+            {!isGuestUser && (
+              <TabPane tabId="pageHistory">
                 {activeComponents.has('pageHistory') && <PageHistory /> }
-              </div>
-            </TabPane>
+              </TabPane>
+            )}
             <TabPane tabId="attachment">
               {activeComponents.has('attachment') && <PageAttachment />}
             </TabPane>
-            {!isGuestUserMode && (
+            {!isGuestUser && (
               <TabPane tabId="shareLink">
                 {activeComponents.has('shareLink') && <ShareLink />}
               </TabPane>
@@ -134,7 +147,8 @@ const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal,
 PageAccessoriesModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-  isGuestUserMode: PropTypes.bool.isRequired,
+  isGuestUser: PropTypes.bool.isRequired,
+  isSharedUser: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
 };

+ 60 - 53
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Fragment, useMemo } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -16,60 +16,66 @@ import SeenUserInfo from './User/SeenUserInfo';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 const PageAccessoriesModalControl = (props) => {
-  const { t, pageAccessoriesContainer, isGuestUserMode } = props;
+  const {
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
+  } = props;
+
+  const accessoriesBtnList = useMemo(() => {
+    return [
+      {
+        name: 'pagelist',
+        Icon: <PageListIcon />,
+        disabled: isSharedUser,
+      },
+      {
+        name: 'timeline',
+        Icon: <TimeLineIcon />,
+        disabled: isSharedUser,
+      },
+      {
+        name: 'pageHistory',
+        Icon: <HistoryIcon />,
+        disabled: isGuestUser || isSharedUser,
+      },
+      {
+        name: 'attachment',
+        Icon: <AttachmentIcon />,
+        disabled: false,
+      },
+      {
+        name: 'shareLink',
+        Icon: <ShareLinkIcon />,
+        disabled: isGuestUser || isSharedUser,
+      },
+    ];
+  }, [isGuestUser, isSharedUser]);
 
   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 className="grw-page-accessories-control d-flex align-items-center justify-content-between pb-1">
+      {accessoriesBtnList.map((accessory) => {
+        return (
+          <Fragment key={accessory.name}>
+            <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
+              <button
+                type="button"
+                className={`btn btn-link grw-btn-page-accessories ${accessory.disabled ? 'disabled' : ''}`}
+                onClick={() => pageAccessoriesContainer.openPageAccessoriesModal(accessory.name)}
+              >
+                {accessory.Icon}
+              </button>
+            </div>
+            {accessory.disabled && (
+              <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
+                {t('Not available for guest')}
+              </UncontrolledTooltip>
+            )}
+          </Fragment>
+        );
+      })}
+      <div className="d-flex align-items-center">
+        <span className="border-left grw-border-vr">&nbsp;</span>
+        <SeenUserInfo disabled={isSharedUser} />
       </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>
   );
 };
@@ -83,7 +89,8 @@ PageAccessoriesModalControl.propTypes = {
 
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
 
-  isGuestUserMode: PropTypes.bool.isRequired,
+  isGuestUser: PropTypes.bool.isRequired,
+  isSharedUser: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);

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

@@ -18,7 +18,7 @@ class PageAttachment extends React.Component {
     this.state = {
       activePage: 1,
       totalAttachments: 0,
-      limit: null,
+      limit: Infinity,
       attachments: [],
       inUse: {},
       attachmentToDelete: null,

+ 22 - 20
src/client/js/components/PageComment/CommentEditor.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 
 import {
   Button,
-  TabContent, TabPane, Nav, NavItem, NavLink,
+  TabContent, TabPane,
 } from 'reactstrap';
 
 import * as toastr from 'toastr';
@@ -21,6 +21,20 @@ import SlackNotification from '../SlackNotification';
 
 import CommentPreview from './CommentPreview';
 import NotAvailableForGuest from '../NotAvailableForGuest';
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+
+const navTabMapping = {
+  comment_editor: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Write',
+    index: 0,
+  },
+  comment_preview: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Preview',
+    index: 1,
+  },
+};
 
 /**
  *
@@ -43,7 +57,7 @@ class CommentEditor extends React.Component {
       comment: this.props.commentBody || '',
       isMarkdown: true,
       html: '',
-      activeTab: 1,
+      activeTab: 'comment_editor',
       isUploadable,
       isUploadableFile,
       errorMessage: undefined,
@@ -94,7 +108,7 @@ class CommentEditor extends React.Component {
       comment: '',
       isMarkdown: true,
       html: '',
-      activeTab: 1,
+      activeTab: 'comment_editor',
       errorMessage: undefined,
     });
     // reset value
@@ -280,25 +294,13 @@ class CommentEditor extends React.Component {
       </Button>
     );
 
+
     return (
       <>
         <div className="comment-write">
-          <Nav tabs>
-            <NavItem>
-              <NavLink type="button" className={activeTab === 1 ? 'active' : ''} onClick={() => this.handleSelect(1)}>
-                    Write
-              </NavLink>
-            </NavItem>
-            { this.state.isMarkdown && (
-            <NavItem>
-              <NavLink type="button" className={activeTab === 2 ? 'active' : ''} onClick={() => this.handleSelect(2)}>
-                      Preview
-              </NavLink>
-            </NavItem>
-                ) }
-          </Nav>
+          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
-            <TabPane tabId={1}>
+            <TabPane tabId="comment_editor">
               <Editor
                 ref={(c) => { this.editor = c }}
                 value={this.state.comment}
@@ -313,7 +315,7 @@ class CommentEditor extends React.Component {
                 onCtrlEnter={this.ctrlEnterHandler}
               />
             </TabPane>
-            <TabPane tabId={2}>
+            <TabPane tabId="comment_preview">
               <div className="comment-form-preview">
                 {commentPreview}
               </div>
@@ -324,7 +326,7 @@ class CommentEditor extends React.Component {
         <div className="comment-submit">
           <div className="d-flex">
             <label className="mr-2">
-              {activeTab === 1 && (
+              {activeTab === 'comment_editor' && (
               <span className="custom-control custom-checkbox">
                 <input
                   type="checkbox"

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

@@ -148,7 +148,7 @@ class PageComments extends React.Component {
     }
 
     return (
-      <div key={commentId} className={`mb-5 ${rootClassNames}`}>
+      <div key={commentId} className={rootClassNames}>
         <Comment
           comment={comment}
           deleteBtnClicked={this.confirmToDeleteComment}

+ 3 - 3
src/client/js/components/PageContentFooter.jsx

@@ -14,12 +14,12 @@ const PageContentFooter = (props) => {
   } = pageContainer.state;
 
   return (
-    <div className="page-content-footer mt-5 py-4 d-edit-none d-print-none">
+    <div className="page-content-footer py-4 d-edit-none d-print-none">
       <div className="container-lg">
-        <p className="page-meta">
+        <div className="page-meta">
           <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
           <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />
-        </p>
+        </div>
       </div>
     </div>
   );

+ 4 - 4
src/client/js/components/PageEditor/EditorNavbarBottom.jsx

@@ -24,7 +24,7 @@ const EditorNavbarBottom = (props) => {
   const {
     navigationContainer,
   } = props;
-  const { editorMode, isDrawerMode, isDeviceSmallerThanMd } = navigationContainer.state;
+  const { editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
 
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
@@ -76,12 +76,12 @@ const EditorNavbarBottom = (props) => {
         </Collapse>
         )
       }
-      <div className={`navbar navbar-expand border-top px-2 ${additionalClasses.join(' ')}`}>
+      <div className={`navbar navbar-expand border-top px-2 px-md-3 ${additionalClasses.join(' ')}`}>
         <form className="form-inline">
-          { isDrawerMode && renderDrawerButton() }
+          { isDeviceSmallerThanMd && renderDrawerButton() }
           { isOptionsSelectorEnabled && !isDeviceSmallerThanMd && <OptionsSelector /> }
         </form>
-        <form className="form-inline ml-auto">
+        <form className="form-inline flex-nowrap ml-auto">
           {/* Responsive Design for the SlackNotification */}
           {/* Button or the normal Slack banner */}
           {hasSlackConfig && (isDeviceSmallerThanMd ? (

+ 5 - 5
src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -110,7 +110,7 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="input-group">
+      <div className="input-group flex-nowrap">
         <div className="input-group-prepend">
           <span className="input-group-text" id="igt-theme">Theme</span>
         </div>
@@ -146,7 +146,7 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="input-group">
+      <div className="input-group flex-nowrap">
         <div className="input-group-prepend">
           <span className="input-group-text" id="igt-keymap">Keymap</span>
         </div>
@@ -247,9 +247,9 @@ class OptionsSelector extends React.Component {
   render() {
     return (
       <div className="d-flex flex-row">
-        <span className="ml-3">{this.renderThemeSelector()}</span>
-        <span className="ml-4">{this.renderKeymapModeSelector()}</span>
-        <span className="ml-4">{this.renderConfigurationDropdown()}</span>
+        <span>{this.renderThemeSelector()}</span>
+        <span className="ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
+        <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
       </div>
     );
   }

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

@@ -19,7 +19,7 @@ const PageList = (props) => {
 
   const [activePage, setActivePage] = useState(1);
   const [totalPages, setTotalPages] = useState(0);
-  const [limit, setLimit] = useState(null);
+  const [limit, setLimit] = useState(Infinity);
 
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
@@ -43,7 +43,7 @@ const PageList = (props) => {
   if (isLoading) {
     return (
       <div className="wiki">
-        <div className="text-muted test-center">
+        <div className="text-muted text-center">
           <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
         </div>
       </div>

+ 55 - 76
src/client/js/components/PaginationWrapper.jsx

@@ -1,42 +1,31 @@
-import React from 'react';
+import React, { useCallback, useMemo } from 'react';
 import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
-
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
-class PaginationWrapper extends React.Component {
+/**
+ *
+ * @author Mikitaka Itizawa <itizawa@weseek.co.jp>
+ *
+ * @export
+ * @class PaginationWrapper
+ * @extends {React.Component}
+ */
 
-  constructor(props) {
-    super(props);
+const PaginationWrapper = React.memo((props) => {
+  const {
+    activePage, changePage, totalItemsCount, pagingLimit, align,
+  } = props;
 
-    this.state = {
-      activePage: 1,
-      totalItemsCount: 0,
-      paginationNumbers: {},
-      limit: this.props.pagingLimit || Infinity,
-    };
+  /**
+   * various numbers used to generate pagination dom
+   */
+  const paginationNumbers = useMemo(() => {
+    // avoid using null
+    const limit = pagingLimit || Infinity;
 
-    this.calculatePagination = this.calculatePagination.bind(this);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.setState({
-      activePage: nextProps.activePage,
-      totalItemsCount: nextProps.totalItemsCount,
-      limit: nextProps.pagingLimit,
-    }, () => {
-      const activePage = this.state.activePage;
-      const totalCount = this.state.totalItemsCount;
-      const limit = this.state.limit;
-      const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
-      this.setState({ paginationNumbers });
-    });
-  }
-
-  calculatePagination(limit, totalCount, activePage) {
     // calc totalPageNumber
-    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+    const totalPage = Math.floor(totalItemsCount / limit) + (totalItemsCount % limit === 0 ? 0 : 1);
 
     let paginationStart = activePage - 2;
     let maxViewPageNum = activePage + 2;
@@ -58,22 +47,26 @@ class PaginationWrapper extends React.Component {
       paginationStart,
       maxViewPageNum,
     };
-  }
+  }, [activePage, totalItemsCount, pagingLimit]);
+
+  const { paginationStart } = paginationNumbers;
+  const { maxViewPageNum } = paginationNumbers;
+  const { totalPage } = paginationNumbers;
 
   /**
-    * generate Elements of Pagination First Prev
-    * ex.  <<   <   1  2  3  >  >>
-    * this function set << & <
-    */
-  generateFirstPrev(activePage) {
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  const generateFirstPrev = useCallback(() => {
     const paginationItems = [];
     if (activePage !== 1) {
       paginationItems.push(
         <PaginationItem key="painationItemFirst">
-          <PaginationLink first onClick={() => { return this.props.changePage(1) }} />
+          <PaginationLink first onClick={() => { return changePage(1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemPrevious">
-          <PaginationLink previous onClick={() => { return this.props.changePage(activePage - 1) }} />
+          <PaginationLink previous onClick={() => { return changePage(activePage - 1) }} />
         </PaginationItem>,
       );
     }
@@ -88,41 +81,41 @@ class PaginationWrapper extends React.Component {
       );
     }
     return paginationItems;
-  }
+  }, [activePage, changePage]);
 
   /**
    * generate Elements of Pagination First Prev
    *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
    * this function set  numbers
    */
-  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+  const generatePaginations = useCallback(() => {
     const paginationItems = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
         <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
-          <PaginationLink onClick={() => { return this.props.changePage(number) }}>
+          <PaginationLink onClick={() => { return changePage(number) }}>
             {number}
           </PaginationLink>
         </PaginationItem>,
       );
     }
     return paginationItems;
-  }
+  }, [activePage, changePage, paginationStart, maxViewPageNum]);
 
   /**
    * generate Elements of Pagination First Prev
    * ex.  <<   <   1  2  3  >  >>
    * this function set > & >>
    */
-  generateNextLast(activePage, totalPage) {
+  const generateNextLast = useCallback(() => {
     const paginationItems = [];
     if (totalPage !== activePage) {
       paginationItems.push(
         <PaginationItem key="painationItemNext">
-          <PaginationLink next onClick={() => { return this.props.changePage(activePage + 1) }} />
+          <PaginationLink next onClick={() => { return changePage(activePage + 1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemLast">
-          <PaginationLink last onClick={() => { return this.props.changePage(totalPage) }} />
+          <PaginationLink last onClick={() => { return changePage(totalPage) }} />
         </PaginationItem>,
       );
     }
@@ -137,13 +130,11 @@ class PaginationWrapper extends React.Component {
       );
     }
     return paginationItems;
+  }, [activePage, changePage, totalPage]);
 
-  }
-
-  getListClassName() {
+  const getListClassName = useMemo(() => {
     const listClassNames = [];
 
-    const { align } = this.props;
     if (align === 'center') {
       listClassNames.push('justify-content-center');
     }
@@ -152,38 +143,25 @@ class PaginationWrapper extends React.Component {
     }
 
     return listClassNames.join(' ');
-  }
-
-  render() {
-    const paginationItems = [];
+  }, [align]);
 
-    const activePage = this.state.activePage;
-    const totalPage = this.state.paginationNumbers.totalPage;
-    const paginationStart = this.state.paginationNumbers.paginationStart;
-    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
-    const firstPrevItems = this.generateFirstPrev(activePage);
-    paginationItems.push(firstPrevItems);
-    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
-    paginationItems.push(paginations);
-    const nextLastItems = this.generateNextLast(activePage, totalPage);
-    paginationItems.push(nextLastItems);
+  return (
+    <React.Fragment>
+      <Pagination size={props.size} listClassName={getListClassName}>
+        {generateFirstPrev()}
+        {generatePaginations()}
+        {generateNextLast()}
+      </Pagination>
+    </React.Fragment>
+  );
 
-    return (
-      <React.Fragment>
-        <Pagination size={this.props.size} listClassName={this.getListClassName()}>{paginationItems}</Pagination>
-      </React.Fragment>
-    );
-  }
-
-
-}
+});
 
 PaginationWrapper.propTypes = {
-
   activePage: PropTypes.number.isRequired,
   changePage: PropTypes.func.isRequired,
   totalItemsCount: PropTypes.number.isRequired,
-  pagingLimit: PropTypes.number.isRequired,
+  pagingLimit: PropTypes.number,
   align: PropTypes.string,
   size: PropTypes.string,
 };
@@ -191,6 +169,7 @@ PaginationWrapper.propTypes = {
 PaginationWrapper.defaultProps = {
   align: 'left',
   size: 'md',
+  pagingLimit: Infinity,
 };
 
-export default withTranslation()(PaginationWrapper);
+export default PaginationWrapper;

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

@@ -67,7 +67,7 @@ class RecentCreated extends React.Component {
 
     return (
       <div className="page-list-container-create">
-        <ul className="page-list-ul page-list-ul-flat">
+        <ul className="page-list-ul page-list-ul-flat mb-3">
           {pageList}
         </ul>
         <PaginationWrapper

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

@@ -72,7 +72,7 @@ class SavePageControls extends React.Component {
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
-      <div className="d-flex align-items-center form-inline">
+      <div className="d-flex align-items-center form-inline flex-nowrap">
 
         {this.isAclEnabled
           && (

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

@@ -185,7 +185,7 @@ class SearchResult extends React.Component {
       const pageId = `#id_${page._id}`;
       return (
         <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}>
+          <a className="nav-link page-list-link d-flex align-items-baseline" href={pageId}>
             <Page page={page} noLink />
             <div className="ml-auto d-flex">
               { this.state.deletionMode

+ 1 - 1
src/client/js/components/ShareLink/ShareLinkForm.jsx

@@ -22,7 +22,7 @@ class ShareLinkForm extends React.Component {
       numberOfDays: '7',
       description: '',
       customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
-      customExpirationTime: dateFnsFormat(new Date(), 'hh:mm'),
+      customExpirationTime: dateFnsFormat(new Date(), 'HH:mm'),
     };
 
     this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);

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

@@ -73,7 +73,7 @@ class SidebarNav extends React.Component {
           {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
           {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" />}
+          <SecondaryItem label="Trash" iconName="delete" href="/trash" />
         </div>
       </div>
     );

+ 21 - 9
src/client/js/components/TableOfContents.jsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
 const TableOfContents = (props) => {
 
-  const { pageContainer, navigationContainer } = props;
+  const { t, pageContainer, navigationContainer } = props;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
 
@@ -59,14 +59,24 @@ const TableOfContents = (props) => {
       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,
-      }}
-      />
+      { tocHtml !== ''
+      ? (
+        <div
+          id="revision-toc-content"
+          className="revision-toc-content mb-3"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: tocHtml }}
+        />
+      )
+      : (
+        <div
+          id="revision-toc-content"
+          className="revision-toc-content mb-2"
+        >
+          <span className="text-muted">({t('page_table_of_contents.empty')})</span>
+        </div>
+      ) }
+
     </StickyStretchableScroller>
   );
 
@@ -78,6 +88,8 @@ const TableOfContents = (props) => {
 const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
 
 TableOfContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };

+ 13 - 11
src/client/js/components/TrashPageList.jsx

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

+ 3 - 2
src/client/js/components/User/SeenUserInfo.jsx

@@ -18,14 +18,14 @@ import FootstampIcon from '../FootstampIcon';
 const SeenUserInfo = (props) => {
   const [popoverOpen, setPopoverOpen] = useState(false);
   const toggle = () => setPopoverOpen(!popoverOpen);
-  const { pageContainer } = props;
+  const { pageContainer, disabled } = props;
   return (
     <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>
       </Button>
-      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy">
+      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy" disabled={disabled}>
         <PopoverBody className="seen-user-popover">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
             <UserPictureList users={pageContainer.state.seenUsers} />
@@ -38,6 +38,7 @@ const SeenUserInfo = (props) => {
 
 SeenUserInfo.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  disabled: PropTypes.bool,
 };
 
 /**

+ 1 - 1
src/client/js/components/User/UserInfo.jsx

@@ -12,7 +12,7 @@ const UserInfo = (props) => {
   }
 
   return (
-    <div className="grw-users-info d-flex align-items-center d-edit-none pb-2 border-bottom">
+    <div className="grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom">
       <UserPicture user={pageUser} />
 
       <div className="users-meta">

+ 9 - 46
src/client/js/legacy/crowi.js

@@ -154,59 +154,22 @@ Crowi.highlightSelectedSection = function(hash) {
   }
 };
 
-$(() => {
-  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');
-
-  $('[data-toggle="popover"]').popover();
-  $('[data-toggle="tooltip"]').tooltip();
-  $('[data-tooltip-stay]').tooltip('show');
-
-  $('#toggle-crowi-sidebar').click((e) => {
-    const $body = $('body');
-    if ($body.hasClass('aside-hidden')) {
-      $body.removeClass('aside-hidden');
-      $.cookie('aside-hidden', 0, { expires: 30, path: '/' });
-    }
-    else {
-      $body.addClass('aside-hidden');
-      $.cookie('aside-hidden', 1, { expires: 30, path: '/' });
-    }
-    return false;
-  });
-
-  if ($.cookie('aside-hidden') === 1) {
-    $('body').addClass('aside-hidden');
-  }
-
-  $('.copy-link').on('click', function() {
-    $(this).select();
-  });
-
-  if (pageId) {
-
-    if (!isSeen) {
-      $.post('/_api/pages.seen', { page_id: pageId }, (res) => {
-        // ignore unless response has error
-        if (res.ok && res.seenUser) {
-          $('#content-main').data('page-is-seen', 1);
-        }
-      });
-    }
-  } // end if pageId
-});
-
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
+  const pageContainer = appContainer.getContainer('PageContainer');
+
+  // Do nothing if the page does not exist
+  // ex.) admin page,login page
+  if (pageContainer == null) {
+    return null;
+  }
+  const { isAbleToOpenPageEditor } = pageContainer;
 
   // hash on page
   if (window.location.hash) {
     const navigationContainer = appContainer.getContainer('NavigationContainer');
 
-    if (window.location.hash === '#edit') {
+    if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
       navigationContainer.setEditorMode('edit');
 
       // focus

+ 6 - 3
src/client/js/services/AppContainer.js

@@ -39,9 +39,6 @@ export default class AppContainer extends Container {
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
-    const isSharedPageElem = document.getElementById('is-shared-page');
-    this.isSharedUser = (isSharedPageElem != null);
-
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
 
@@ -50,6 +47,12 @@ export default class AppContainer extends Container {
       this.currentUser = JSON.parse(currentUserElem.textContent);
     }
 
+    const isSharedPageElem = document.getElementById('is-shared-page');
+
+    // check what kind of user
+    this.isGuestUser = this.currentUser == null;
+    this.isSharedUser = isSharedPageElem != null && this.currentUser == null;
+
     const userLocaleId = this.currentUser?.lang;
     this.i18n = i18nFactory(userLocaleId);
 

+ 4 - 0
src/client/js/services/NavigationContainer.js

@@ -176,6 +176,10 @@ export default class NavigationContainer extends Container {
   }
 
   openPageCreateModal() {
+    if (this.appContainer.currentUser == null) {
+      logger.warn('Please login or signup to create a new page.');
+      return;
+    }
     this.setState({ isPageCreateModalShown: true });
   }
 

+ 151 - 46
src/client/js/services/PageContainer.js

@@ -60,12 +60,15 @@ export default class PageContainer extends Container {
       sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
+
+      isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       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')),
+      isPageExist: mainContent.getAttribute('data-page-id') != null,
+
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       tags: null,
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
@@ -101,9 +104,19 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
-    this.retrieveSeenUsers();
     this.initStateMarkdown();
-    this.initStateOthers();
+    this.checkAndUpdateImageUrlCached(this.state.likerUsers);
+
+    const { isSharedUser } = this.appContainer;
+
+    // see https://dev.growi.org/5fabddf8bbeb1a0048bcb9e9
+    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
+
+    if (isAbleToGetAttachedInformationAboutPages) {
+      this.retrieveSeenUsers();
+      this.retrieveLikeInfo();
+      this.retrieveBookmarkInfo();
+    }
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
@@ -133,6 +146,101 @@ export default class PageContainer extends Container {
     return 'PageContainer';
   }
 
+
+  get isAbleToOpenPageEditor() {
+    const { isNotCreatable, isTrashPage } = this.state;
+    const { isGuestUser } = this.appContainer;
+
+    return (!isNotCreatable && !isTrashPage && !isGuestUser);
+  }
+
+  /**
+   * whether to display reaction buttons
+   * ex.) like, bookmark
+   */
+  get isAbleToShowPageReactionButtons() {
+    const { isTrashPage, isPageExist } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (!isTrashPage && isPageExist && !isSharedUser);
+  }
+
+  /**
+   * whether to display tag labels
+   */
+  get isAbleToShowTagLabel() {
+    const { isUserPage } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (!isUserPage && !isSharedUser);
+  }
+
+  /**
+   * whether to display page management
+   * ex.) duplicate, rename
+   */
+  get isAbleToShowPageManagement() {
+    const { isPageExist, isTrashPage } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (isPageExist && !isTrashPage && !isSharedUser);
+  }
+
+  /**
+   * whether to display pageEditorModeManager
+   * ex.) view, edit, hackmd
+   */
+  get isAbleToShowPageEditorModeManager() {
+    const { isNotCreatable, isTrashPage } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (!isNotCreatable && !isTrashPage && !isSharedUser);
+  }
+
+  /**
+   * whether to display pageAuthors
+   * ex.) creator, lastUpdateUser
+   */
+  get isAbleToShowPageAuthors() {
+    const { isPageExist, isUserPage } = this.state;
+
+    return (isPageExist && !isUserPage);
+  }
+
+  /**
+   * whether to like button
+   * not displayed on user page
+   */
+  get isAbleToShowLikeButton() {
+    const { isUserPage } = this.state;
+    const { isSharedUser } = this.appContainer;
+
+    return (!isUserPage && !isSharedUser);
+  }
+
+  /**
+   * whether to Empty Trash Page
+   * not displayed when guest user and not on trash page
+   */
+  get isAbleToShowEmptyTrashButton() {
+    const { currentUser } = this.appContainer;
+    const { path, hasChildren } = this.state;
+
+    return (currentUser != null && currentUser.admin && path === '/trash' && hasChildren);
+  }
+
+  /**
+   * whether to display trash management buttons
+   * ex.) undo, delete completly
+   * not displayed when guest user
+   */
+  get isAbleToShowTrashPageManagementButtons() {
+    const { currentUser } = this.appContainer;
+    const { isDeleted } = this.state;
+
+    return (isDeleted && currentUser != null);
+  }
+
   /**
    * initialize state for markdown data
    */
@@ -155,19 +263,13 @@ export default class PageContainer extends Container {
     this.checkAndUpdateImageUrlCached(users);
   }
 
-  async initStateOthers() {
-
-    this.retrieveLikeInfo();
-    this.retrieveBookmarkInfo();
-    this.checkAndUpdateImageUrlCached(this.state.likerUsers);
-  }
-
   async retrieveLikeInfo() {
-    const like = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
+    const res = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
+    const { sumOfLikers, isLiked } = res.data;
+
     this.setState({
-      sumOfLikers: like.data.sumOfLikers,
-      likerUsers: like.data.users.liker,
-      isLiked: like.data.isLiked,
+      sumOfLikers,
+      isLiked,
     });
   }
 
@@ -180,14 +282,11 @@ export default class PageContainer extends Container {
   }
 
   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 });
+    const response = await this.appContainer.apiv3Get('/bookmarks/info', { pageId: this.state.pageId });
+    this.setState({
+      sumOfBookmarks: response.data.sumOfBookmarks,
+      isBookmarked: response.data.isBookmarked,
+    });
   }
 
   async toggleBookmark() {
@@ -216,12 +315,18 @@ export default class PageContainer extends Container {
     return this.appContainer.getContainer('NavigationContainer');
   }
 
-  setLatestRemotePageData(page, user) {
-    this.setState({
-      remoteRevisionId: page.revision._id,
-      revisionIdHackmdSynced: page.revisionHackmdSynced,
-      lastUpdateUsername: user.name,
-    });
+  setLatestRemotePageData(s2cMessagePageUpdated) {
+    const newState = {
+      remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
+    };
+
+    if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
+      newState.hasDraftOnHackmd = s2cMessagePageUpdated.hasDraftOnHackmd;
+    }
+
+    this.setState(newState);
   }
 
   setTocHtml(tocHtml) {
@@ -247,6 +352,8 @@ export default class PageContainer extends Container {
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       markdown: page.revision.body,
+      createdAt: page.createdAt,
+      updatedAt: page.updatedAt,
     };
     if (tags != null) {
       newState.tags = tags;
@@ -256,7 +363,7 @@ export default class PageContainer extends Container {
     // PageEditor component
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     if (pageEditor != null) {
-      if (editorMode !== 'builtin') {
+      if (editorMode !== 'edit') {
         pageEditor.updateEditorValue(newState.markdown);
       }
     }
@@ -468,9 +575,10 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
 
-      // update PageStatusAlert
-      if (data.page.path === pageContainer.state.path) {
-        this.setLatestRemotePageData(data.page, data.user);
+      // update remote page data
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
       }
     });
 
@@ -482,16 +590,10 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
-      if (data.page.path === pageContainer.state.path) {
-        // update PageStatusAlert
-        pageContainer.setLatestRemotePageData(data.page, data.user);
-        // update remote data
-        const page = data.page;
-        pageContainer.setState({
-          remoteRevisionId: page.revision._id,
-          revisionIdHackmdSynced: page.revisionHackmdSynced,
-          hasDraftOnHackmd: page.hasDraftOnHackmd,
-        });
+      // update remote page data
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
       }
     });
 
@@ -503,9 +605,10 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
 
-      // update PageStatusAlert
-      if (data.page.path === pageContainer.state.path) {
-        pageContainer.setLatestRemotePageData(data.page, data.user);
+      // update remote page data
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
       }
     });
 
@@ -517,7 +620,9 @@ export default class PageContainer extends Container {
 
       logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
 
-      if (data.page.path === pageContainer.state.path) {
+      // update isHackmdDraftUpdatingInRealtime
+      const { s2cMessagePageUpdated } = data;
+      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
         pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
       }
     });

+ 1 - 1
src/client/js/services/PageHistoryContainer.js

@@ -28,7 +28,7 @@ export default class PageHistoryContainer extends Container {
 
       totalPages: 0,
       activePage: 1,
-      pagingLimit: null,
+      pagingLimit: Infinity,
     };
 
     this.retrieveRevisions = this.retrieveRevisions.bind(this);

+ 7 - 3
src/client/js/services/TagContainer.js

@@ -39,11 +39,15 @@ export default class TagContainer extends Container {
       return;
     }
 
-    const { pageId, templateTagData } = pageContainer.state;
+    const { pageId, templateTagData, shareLinkId } = pageContainer.state;
+
+    if (shareLinkId != null) {
+      return;
+    }
 
     let tags = [];
-    // when the page exists
-    if (pageId != null) {
+    // when the page exists or shared page
+    if (pageId != null && shareLinkId == null) {
       const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });
       tags = res.tags;
     }

+ 3 - 3
src/client/styles/scss/_admin.scss

@@ -40,7 +40,7 @@
   }
 
   .admin-setting-header {
-    border-bottom: 1px solid #dee2e6;
+    border-bottom: 1px solid transparent;
   }
 
   .admin-security {
@@ -52,7 +52,7 @@
     }
 
     .auth-mechanism-configurations {
-      min-height: 300px;
+      min-height: 80vh;
     }
   }
 
@@ -142,7 +142,7 @@
     // style
     .theme-option-container a {
       background-color: $gray-50;
-      border: 1px solid $gray-300;
+      border: 1px solid $border-color;
     }
     .theme-option-name {
       opacity: 0.3;

+ 5 - 11
src/client/styles/scss/_layout.scss

@@ -16,7 +16,7 @@ body {
 
 .grw-modal-head {
   font-size: 1em;
-  border-bottom: 1px solid $gray-500;
+  border-bottom: 1px solid transparent;
 }
 
 // padding settings for GrowiNavbarBottom
@@ -36,6 +36,10 @@ body {
   }
 }
 
+.grw-side-contents-container {
+  margin-left: 30px;
+}
+
 .grw-side-contents-sticky-container {
   position: sticky;
   // growisubnavigation + grw-navbar-boder
@@ -103,16 +107,6 @@ body {
       margin-bottom: 20px;
       font-size: 0.9em;
       border: solid 1px $gray-400;
-
-      .revision-toc-head {
-        display: inline-block;
-        float: none;
-      }
-
-      .revision-toc-content.collapse {
-        display: block;
-        height: auto;
-      }
     }
 
     .meta {

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

@@ -1,9 +1,5 @@
 .user-settings-page {
   .title {
-    padding: 0.5rem 15px;
-
-    line-height: 1em;
-
     @include variable-font-size(28px);
     line-height: 1.1em;
   }

+ 3 - 46
src/client/styles/scss/_mixins.scss

@@ -10,7 +10,7 @@
   @include media-breakpoint-only(lg) {
     font-size: #{$basesize * 0.9};
   }
-  @include media-breakpoint-only(xl) {
+  @include media-breakpoint-up(xl) {
     font-size: $basesize;
   }
 }
@@ -227,7 +227,7 @@
   transition-duration: 300ms;
 }
 
-@mixin border-vertical($beforeOrAfter, $borderColor, $borderLength, $zIndex: initial, $isBtnGroup: false) {
+@mixin border-vertical($beforeOrAfter, $borderLength, $zIndex: initial, $isBtnGroup: false) {
   position: relative;
   @if $isBtnGroup {
     &:not(:first-child) {
@@ -248,51 +248,8 @@
       height: $borderLength;
       margin-left: -0.5px;
       content: '';
-      border-left: 1px solid $borderColor;
+      border-left: 1px solid transparent;
       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;
-  }
-}

+ 11 - 8
src/client/styles/scss/_navbar.scss

@@ -76,20 +76,23 @@
   }
 }
 
-.grw-custom-nav {
+.grw-custom-nav-tab,
+.grw-custom-nav-dropdown {
+  svg {
+    width: 17px;
+    height: 17px;
+    margin-right: 5px;
+    vertical-align: text-bottom;
+  }
+}
+
+.grw-custom-nav-tab {
   .nav-title {
     flex-wrap: nowrap;
   }
 
   .nav-link {
     padding: 1rem 1.5rem;
-
-    svg {
-      width: 17px;
-      height: 17px;
-      margin-right: 5px;
-      vertical-align: text-bottom;
-    }
   }
 
   .grw-nav-slide-hr {

+ 29 - 18
src/client/styles/scss/_on-edit.scss

@@ -11,8 +11,9 @@ body:not(.on-edit) {
 body.on-edit {
   overflow-y: hidden !important;
 
-  .container {
-    max-width: 100%;
+  .container-fluid {
+    padding-right: 15px;
+    padding-left: 15px;
   }
 
   .grw-navbar {
@@ -22,10 +23,12 @@ body.on-edit {
 
   // restrict height of subnav
   .grw-subnav {
-    max-height: $grw-subnav-max-height-on-edit;
+    height: $grw-subnav-height-on-edit;
+    min-height: unset;
+    padding-top: 0;
 
-    @include media-breakpoint-up(md) {
-      max-height: $grw-subnav-max-height-md-on-edit;
+    @include media-breakpoint-up(lg) {
+      height: $grw-subnav-height-lg-on-edit;
     }
   }
 
@@ -36,12 +39,12 @@ body.on-edit {
   }
 
   // calculate margin
-  $editor-margin-top: $grw-navbar-border-width + $grw-subnav-max-height-on-edit;
+  $editor-margin-top: $grw-navbar-border-width + $grw-subnav-height-on-edit;
   @include expand-editor($editor-margin-top);
 
-  @include media-breakpoint-up(md) {
+  @include media-breakpoint-up(lg) {
     // calculate margin
-    $editor-margin-top: $grw-navbar-border-width + $grw-subnav-max-height-md-on-edit;
+    $editor-margin-top: $grw-navbar-border-width + $grw-subnav-height-lg-on-edit;
     @include expand-editor($editor-margin-top);
   }
 
@@ -50,12 +53,6 @@ body.on-edit {
     display: block !important;
   }
 
-  .d-edit-sm-block {
-    @include media-breakpoint-up(sm) {
-      display: block !important;
-    }
-  }
-
   // hide unnecessary elements
   .d-edit-none {
     display: none !important;
@@ -82,10 +79,6 @@ body.on-edit {
   /*****************
    * Expand Editor
    *****************/
-  .container-fluid {
-    padding-bottom: 0;
-  }
-
   .grw-editor-navbar-bottom {
     height: $grw-editor-navbar-bottom-height;
 
@@ -126,6 +119,24 @@ body.on-edit {
   /*********************
    * Navigation styles
    */
+  .grw-subnav {
+    padding-bottom: 0;
+
+    h1 {
+      font-size: 16px;
+    }
+
+    .grw-drawer-toggler {
+      width: 38px;
+      height: 38px;
+      font-size: 18px;
+    }
+
+    .grw-taglabels-container {
+      margin-bottom: 0;
+    }
+  }
+
   // ellipsis .grw-page-path-hierarchical-link
   .grw-subnav-left-side {
     overflow: hidden;

+ 28 - 0
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -25,6 +25,34 @@ $gray-900: darken($dark, 5%) !default;
 $grays: ("50": $gray-50) !default;
 $red: #ff0a54 !default;
 
+
+// Grid breakpoints
+//
+// Define the minimum dimensions at which your layout will change,
+// adapting to different screen sizes, for use in media queries.
+
+$grid-breakpoints: (
+  xs: 0,
+  sm: 576px,
+  md: 768px,
+  lg: 992px,
+  xl: 1200px,
+  2xl: 1480px
+);
+
+// Grid containers
+//
+// Define the maximum width of `.container` for different screen sizes.
+
+$container-max-widths: (
+  sm: 540px,
+  md: 720px,
+  lg: 960px,
+  xl: 1140px,
+  2xl: 1320px
+);
+
+
 //== Typography
 //
 //## Font, line-height, and color for body text, headings, and more.

+ 2 - 2
src/client/styles/scss/_page-accessories-control.scss

@@ -4,8 +4,7 @@
   border-bottom: 1px solid transparent;
 
   .grw-btn-page-accessories {
-    padding: 0.375rem 0.5rem;
-    margin: 0 0.2rem;
+    padding: 0.375rem;
 
     svg {
       width: 16px;
@@ -15,6 +14,7 @@
 
   .grw-border-vr {
     height: 25px;
+    border-left: solid 1px transparent;
   }
 
   .seen-user-count {

+ 1 - 26
src/client/styles/scss/_page.scss

@@ -1,32 +1,7 @@
 // import diff2html styles
 @import '~diff2html/bundles/css/diff2html.min.css';
 
-.main-container {
-  .url-line {
-    font-size: 1rem;
-    color: $gray-400;
-  }
-
-  h1.title {
-    margin-top: 0;
-    margin-bottom: 0;
-
-    .d-flex {
-      flex-wrap: wrap; // for long page path
-    }
-
-    // crowi layout only
-    a.last-path {
-      color: $gray-300;
-
-      &:hover {
-        color: inherit;
-      }
-    }
-  }
-}
-
-.main .content-main .revision-history {
+.revision-history {
   .revision-history-list {
     .revision-history-outer {
       // add border-top except of first element

+ 0 - 10
src/client/styles/scss/_page_list.scss

@@ -72,13 +72,3 @@ body .page-list {
     background-color: $gray-300;
   }
 }
-
-.grw-page-list-m {
-  .grw-page-list-title-m {
-    svg {
-      width: 35px;
-      height: 35px;
-      margin-bottom: 6px;
-    }
-  }
-}

+ 1 - 0
src/client/styles/scss/_search.scss

@@ -164,6 +164,7 @@
       > li {
         > a {
           padding: 2px 8px;
+          word-break: break-all;
           border-radius: 0;
 
           &:hover {

+ 5 - 1
src/client/styles/scss/_subnav.scss

@@ -27,6 +27,10 @@
     line-height: 1.4em;
   }
 
+  .grw-taglabels-container {
+    margin-bottom: 0.5rem;
+  }
+
   .grw-page-path-nav {
     .separator {
       margin-right: 0.2em;
@@ -106,7 +110,7 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
   z-index: $zindex-sticky - 5;
 
   .grw-subnav {
-    box-shadow: 0px 6px 6px 3px rgba(black, 0.15);
+    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
   }
 }
 

+ 10 - 0
src/client/styles/scss/_user.scss

@@ -43,3 +43,13 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     }
   }
 }
+
+.user-page {
+  .grw-user-page-header {
+    svg {
+      width: 35px;
+      height: 35px;
+      margin-bottom: 6px;
+    }
+  }
+}

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