Selaa lähdekoodia

Merge branch 'master' into imprv/duplicate-Page-with-child

白石誠 5 vuotta sitten
vanhempi
sitoutus
ec3f9b3ced
100 muutettua tiedostoa jossa 1960 lisäystä ja 957 poistoa
  1. 1 1
      .devcontainer/Dockerfile
  2. 4 4
      .github/workflows/ci.yml
  3. 43 2
      CHANGES.md
  4. 1 1
      README.md
  5. 2 2
      docker/Dockerfile
  6. 3 3
      package.json
  7. 1 1
      resource/locales/en_US/sandbox-math.md
  8. 12 3
      resource/locales/en_US/translation.json
  9. 1 1
      resource/locales/ja_JP/sandbox-math.md
  10. 12 3
      resource/locales/ja_JP/translation.json
  11. 1 1
      resource/locales/zh_CN/sandbox-math.md
  12. 13 4
      resource/locales/zh_CN/translation.json
  13. 6 10
      src/client/js/app.jsx
  14. 5 7
      src/client/js/base.jsx
  15. 140 0
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  16. 34 26
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  17. 67 0
      src/client/js/components/Fab.jsx
  18. 15 11
      src/client/js/components/Navbar/DrawerToggler.jsx
  19. 10 30
      src/client/js/components/Navbar/GlobalSearch.jsx
  20. 24 21
      src/client/js/components/Navbar/GrowiNavbar.jsx
  21. 61 0
      src/client/js/components/Navbar/GrowiNavbarBottom.jsx
  22. 136 36
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  23. 0 92
      src/client/js/components/Navbar/GrowiSubNavigationForUserPage.jsx
  24. 88 0
      src/client/js/components/Navbar/GrowiSubNavigationSwitcher.jsx
  25. 0 41
      src/client/js/components/Navbar/PageCreateButton.jsx
  26. 1 1
      src/client/js/components/Navbar/PageCreator.jsx
  27. 6 1
      src/client/js/components/Navbar/RevisionAuthor.jsx
  28. 69 0
      src/client/js/components/Page/RenderTagLabels.jsx
  29. 62 0
      src/client/js/components/Page/TagEditModal.jsx
  30. 0 71
      src/client/js/components/Page/TagEditor.jsx
  31. 63 92
      src/client/js/components/Page/TagLabels.jsx
  32. 1 1
      src/client/js/components/PageEditor.jsx
  33. 78 0
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  34. 40 19
      src/client/js/components/PageEditor/OptionsSelector.jsx
  35. 2 2
      src/client/js/components/PageEditor/PagePathNavForEditor.jsx
  36. 1 3
      src/client/js/components/PageManagement/ApiErrorMessage.jsx
  37. 56 46
      src/client/js/components/PageStatusAlert.jsx
  38. 9 3
      src/client/js/components/SavePageControls/GrantSelector.jsx
  39. 3 1
      src/client/js/components/SearchForm.jsx
  40. 9 4
      src/client/js/components/Sidebar.jsx
  41. 16 13
      src/client/js/components/TableOfContents.jsx
  42. 106 2
      src/client/js/services/AdminOidcSecurityContainer.js
  43. 12 9
      src/client/js/services/EditorContainer.js
  44. 16 2
      src/client/js/services/NavigationContainer.js
  45. 3 1
      src/client/js/services/PageContainer.js
  46. 9 4
      src/client/styles/scss/_admin.scss
  47. 31 5
      src/client/styles/scss/_layout.scss
  48. 2 1
      src/client/styles/scss/_layout_growi.scss
  49. 0 4
      src/client/styles/scss/_login.scss
  50. 6 1
      src/client/styles/scss/_mixins.scss
  51. 12 5
      src/client/styles/scss/_navbar.scss
  52. 2 9
      src/client/styles/scss/_navbar_kibela.scss
  53. 56 58
      src/client/styles/scss/_on-edit.scss
  54. 5 0
      src/client/styles/scss/_override-bootstrap.scss
  55. 8 0
      src/client/styles/scss/_override-rbt.scss
  56. 38 0
      src/client/styles/scss/_page.scss
  57. 29 31
      src/client/styles/scss/_search.scss
  58. 61 31
      src/client/styles/scss/_sidebar.scss
  59. 76 24
      src/client/styles/scss/_subnav.scss
  60. 4 17
      src/client/styles/scss/_tag.scss
  61. 3 31
      src/client/styles/scss/_user.scss
  62. 4 0
      src/client/styles/scss/_variables.scss
  63. 4 4
      src/client/styles/scss/atoms/_buttons.scss
  64. 3 2
      src/client/styles/scss/atoms/_nav.scss
  65. 34 9
      src/client/styles/scss/theme/_apply-colors-dark.scss
  66. 3 4
      src/client/styles/scss/theme/_apply-colors-kibela.scss
  67. 27 4
      src/client/styles/scss/theme/_apply-colors-light.scss
  68. 5 1
      src/client/styles/scss/theme/_apply-colors.scss
  69. 23 0
      src/client/styles/scss/theme/_reboot-bootstrap-border-colors.scss
  70. 3 0
      src/client/styles/scss/theme/_reboot-bootstrap-theme-colors.scss
  71. 11 3
      src/client/styles/scss/theme/default.scss
  72. 2 2
      src/client/styles/scss/theme/nature.scss
  73. 1 0
      src/server/models/config.js
  74. 33 22
      src/server/models/page.js
  75. 0 3
      src/server/models/user.js
  76. 56 0
      src/server/routes/apiv3/security-setting.js
  77. 47 0
      src/server/routes/apiv3/users.js
  78. 4 0
      src/server/routes/attachment.js
  79. 6 6
      src/server/routes/page.js
  80. 12 0
      src/server/service/config-loader.js
  81. 25 0
      src/server/service/file-uploader/local.js
  82. 16 0
      src/server/service/file-uploader/uploader.js
  83. 34 0
      src/server/service/passport.js
  84. 0 5
      src/server/util/swigFunctions.js
  85. 1 13
      src/server/views/_form.html
  86. 2 0
      src/server/views/invited.html
  87. 5 2
      src/server/views/layout-growi/base/layout.html
  88. 0 5
      src/server/views/layout-growi/forbidden.html
  89. 0 5
      src/server/views/layout-growi/not_creatable.html
  90. 0 5
      src/server/views/layout-growi/not_found.html
  91. 4 11
      src/server/views/layout-growi/page.html
  92. 5 11
      src/server/views/layout-growi/page_list.html
  93. 0 11
      src/server/views/layout-growi/user_page.html
  94. 0 1
      src/server/views/layout-growi/widget/header.html
  95. 15 7
      src/server/views/layout-kibela/base/layout.html
  96. 0 6
      src/server/views/layout-kibela/forbidden.html
  97. 0 6
      src/server/views/layout-kibela/not_creatable.html
  98. 0 6
      src/server/views/layout-kibela/not_found.html
  99. 0 6
      src/server/views/layout-kibela/page.html
  100. 0 5
      src/server/views/layout-kibela/page_list.html

+ 1 - 1
.devcontainer/Dockerfile

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

+ 4 - 4
.github/workflows/ci.yml

@@ -13,7 +13,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
 
     steps:
     - uses: actions/checkout@v2
@@ -68,7 +68,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
 
     steps:
     - uses: actions/checkout@v2
@@ -129,7 +129,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
 
     steps:
     - uses: actions/checkout@v2
@@ -200,7 +200,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x, 14.x]
 
     steps:
     - uses: actions/checkout@v2

+ 43 - 2
CHANGES.md

@@ -1,8 +1,49 @@
 # CHANGES
 
-## v4.0.8-RC
+## v4.1.0-RC
 
-* 
+### BREAKING CHANGES
+
+* GROWI v4.1.x no longer support Node.js v10.x
+* GROWI v4.1.x no longer support growi-plugin-attachment-refs@v1
+
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
+
+### Updates
+
+* Support: Support Node.js v14
+
+
+
+## v4.0.10
+
+* Improvement: Adjust ToC height
+* Fix: Fail to rename/delete a page set as "Anyone with the link"
+
+
+## v4.0.9
+
+* Feature: Detailed configurations for OpenID Connect
+    * Authorization Endpoint
+    * Token Endpoint
+    * Revocation Endpoint
+    * Introspection Endpoint
+    * UserInfo Endpoint
+    * Registration Endpoint
+    * JSON Web Key Set URI
+* Improvement: Navigations
+    * New floating subnavigation
+    * New open drawer button
+    * New fixed bottom navbar on mobile
+    * New fixed bottom navbar for editor on mobile
+    * FAB (Floating action button)
+* Improvement: Sticky admin navigation
+* Fix: Reseting password doesn't work
+* Fix: Styles for printing
+* Fix: Unable to create page with original path after emptying trash
+* I18n: Support zh-CN
+
+## v4.0.8  (Missing number)
 
 ## v4.0.7
 

+ 1 - 1
README.md

@@ -92,7 +92,7 @@ Development
 
 ## Dependencies
 
-- Node.js v12.x (DON'T USE 13.x)
+- Node.js v12.x or v14.x
 - npm 6.x
 - yarn
 - MongoDB 3.x

+ 2 - 2
docker/Dockerfile

@@ -7,7 +7,7 @@ ARG flavor=default
 ##
 ## deps-resolver
 ##
-FROM node:12-slim AS deps-resolver
+FROM node:14-slim AS deps-resolver
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi
@@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
 ##
 ## prebuilder-default
 ##
-FROM node:12-slim AS prebuilder-default
+FROM node:14-slim AS prebuilder-default
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.0.8-RC",
+  "version": "4.1.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -103,7 +103,7 @@
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^4.0.8",
+    "growi-commons": "^5.0.3",
     "helmet": "^3.13.0",
     "i18next": "^19.0.0",
     "i18next-express-middleware": "^1.4.1",
@@ -265,7 +265,7 @@
     "debug": "src/lib/service/logger/alias-for-debug"
   },
   "engines": {
-    "node": ">=10.17.0 <13",
+    "node": "^12 || ^14",
     "npm": ">=6.11.3 <7",
     "yarn": ">=1.19.1 <2"
   }

+ 1 - 1
resource/locales/en_US/sandbox-math.md

@@ -4,7 +4,7 @@ See [MathJax](https://www.mathjax.org/).
 
 ## Inline Formula
 
-When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
+When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are
   $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
 
 ## The Lorenz Equations

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

@@ -119,7 +119,6 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
-  "Edit tags for this page": "Edit tags for this page",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Load latest": "Load latest",
@@ -342,7 +341,8 @@
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
     "remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} "
+    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "failed_to_reset_password":"Failed to reset password"
   },
   "template": {
     "modal_label": {
@@ -451,6 +451,14 @@
     "issuerHost": "Issuer Host",
     "scope": "Scope",
     "desc_of_callback_URL": "Use it in the setting of the {{AuthName}} Identity provider",
+    "authorization_endpoint": "Authorization Endpoint",
+    "token_endpoint": "Token Endpoint",
+    "revocation_endpoint": "Revocation Endpoint",
+    "introspection_endpoint": "Introspection Endpoint",
+    "userinfo_endpoint": "UserInfo Endpoint",
+    "end_session_endpoint": "EndSessioin Endpoint",
+    "registration_endpoint": "Registration Endpoint",
+    "jwks_uri": "JSON Web Key Set URL",
     "clientID": "Client ID",
     "client_secret": "Client Secret",
     "updated_general_security_setting": "Succeeded to update security setting",
@@ -576,7 +584,8 @@
         "register_1": "Contant to OIDC IdP Administrator",
         "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above",
-        "updated_oidc": "Succeeded to update OpenID Connect"
+        "updated_oidc": "Succeeded to update OpenID Connect",
+        "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
       },
       "how_to": {
         "google": "How to configure Google OAuth?",

+ 1 - 1
resource/locales/ja_JP/sandbox-math.md

@@ -4,7 +4,7 @@ See [MathJax](https://www.mathjax.org/).
 
 ## Inline Formula
 
-When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
+When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are
   $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
 
 ## The Lorenz Equations

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

@@ -118,7 +118,6 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
-  "Edit tags for this page": "タグを編集する",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
@@ -343,7 +342,8 @@
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました"
+    "remove_external_user_success": "{{accountId}}を削除しました",
+    "failed_to_reset_password":"パスワードのリセットに失敗しました"
   },
   "template": {
     "modal_label": {
@@ -448,6 +448,14 @@
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
+    "authorization_endpoint": "認可エンドポイント",
+    "token_endpoint": "トークンエンドポイント",
+    "revocation_endpoint": "失効エンドポイント",
+    "introspection_endpoint": "検証エンドポイント",
+    "userinfo_endpoint": "ユーザ情報エンドポイント",
+    "end_session_endpoint": "セッション終了エンドポイント",
+    "registration_endpoint": "登録エンドポイント",
+    "jwks_uri": "JSON Web Key Set URL",
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
     "updated_general_security_setting": "セキュリティ設定を更新しました。",
@@ -569,7 +577,8 @@
         "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
-        "updated_oidc": "OpenID Connect を更新しました"
+        "updated_oidc": "OpenID Connect を更新しました",
+        "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
       },
       "how_to": {
         "google": "Google OAuth の設定方法",

+ 1 - 1
resource/locales/zh_CN/sandbox-math.md

@@ -4,7 +4,7 @@ See [MathJax](https://www.mathjax.org/).
 
 ## Inline Formula
 
-When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
+When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are
   $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
 
 ## The Lorenz Equations

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

@@ -125,7 +125,6 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
-	"Edit tags for this page": "编辑标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
 	"Load latest": "家在最新",
@@ -340,8 +339,9 @@
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"remove_user_success": "Succeeded to removing {{username}} ",
-		"remove_external_user_success": "Succeeded to remove {{accountId}} "
-	},
+    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "failed_to_reset_password":"Failed to reset password"
+  },
 	"template": {
 		"modal_label": {
 			"Create/Edit Template Page": "创建/编辑模板页",
@@ -441,6 +441,14 @@
 		"issuerHost": "发行者主机",
 		"scope": "Scope",
 		"desc_of_callback_URL": "在{{AuthName}}身份提供程序的设置中使用它",
+    "authorization_endpoint": "Authorization Endpoint",
+    "token_endpoint": "Token Endpoint",
+    "revocation_endpoint": "Revocation Endpoint",
+    "introspection_endpoint": "Introspection Endpoint",
+    "userinfo_endpoint": "UserInfo Endpoint",
+    "end_session_endpoint": "EndSessioin Endpoint",
+    "registration_endpoint": "Registration Endpoint",
+    "jwks_uri": "JSON Web Key Set URL",
 		"clientID": "Client ID",
 		"client_secret": "客户机密",
 		"updated_general_security_setting": "更新安全设置成功",
@@ -566,7 +574,8 @@
 				"register_1": "Contant to OIDC IdP Administrator",
 				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
 				"register_3": "Copy and paste your ClientID and Client Secret above",
-				"updated_oidc": "Succeeded to update OpenID Connect"
+				"updated_oidc": "Succeeded to update OpenID Connect",
+        "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
 			},
 			"how_to": {
 				"google": "How to configure Google OAuth?",

+ 6 - 10
src/client/js/app.jsx

@@ -10,11 +10,8 @@ import SearchPage from './components/SearchPage';
 import TagsList from './components/TagsList';
 import PageEditor from './components/PageEditor';
 import PagePathNavForEditor from './components/PageEditor/PagePathNavForEditor';
-// eslint-disable-next-line import/no-duplicates
-import OptionsSelector from './components/PageEditor/OptionsSelector';
-// eslint-disable-next-line import/no-duplicates
+import EditorNavbarBottom from './components/PageEditor/EditorNavbarBottom';
 import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
-import SavePageControls from './components/SavePageControls';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
@@ -38,7 +35,7 @@ import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
-import GrowiSubNavigationForUserPage from './components/Navbar/GrowiSubNavigationForUserPage';
+import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSwitcher';
 import PersonalContainer from './services/PersonalContainer';
 
 import { appContainer, componentMappings } from './base';
@@ -74,7 +71,7 @@ Object.assign(componentMappings, {
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
 
-  'page-status-alert': <PageStatusAlert />,
+  'grw-page-status-alert-container': <PageStatusAlert />,
 
   'trash-page-alert': <TrashPageAlert />,
 
@@ -103,8 +100,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     'page': <Page />,
-    'grw-subnav': <GrowiSubNavigation />,
-    'grw-subnav-for-user-page': <GrowiSubNavigationForUserPage />,
+    'grw-subnav-container': <GrowiSubNavigation />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
   });
 }
 // additional definitions if user is logged in
@@ -112,8 +109,7 @@ if (appContainer.currentUser != null) {
   Object.assign(componentMappings, {
     'page-editor': <PageEditor />,
     'page-editor-path-nav': <PagePathNavForEditor />,
-    'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
-    'save-page-controls': <SavePageControls />,
+    'page-editor-navbar-bottom-container': <EditorNavbarBottom />,
   });
   if (pageContainer.state.pageId != null) {
     Object.assign(componentMappings, {

+ 5 - 7
src/client/js/base.jsx

@@ -3,15 +3,14 @@ import React from 'react';
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
 
-import SearchTop from './components/Navbar/SearchTop';
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
-import NavbarToggler from './components/Navbar/NavbarToggler';
+import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
+import Fab from './components/Fab';
 import StaffCredit from './components/StaffCredit/StaffCredit';
 
 import AppContainer from './services/AppContainer';
 import WebsocketContainer from './services/WebsocketContainer';
-import PageCreateButton from './components/Navbar/PageCreateButton';
 import PageCreateModal from './components/PageCreateModal';
 
 const logger = loggerFactory('growi:cli:app');
@@ -40,15 +39,14 @@ logger.info('AppContainer has been initialized');
  */
 const componentMappings = {
   'grw-navbar': <GrowiNavbar />,
-  'grw-navbar-toggler': <NavbarToggler />,
+  'grw-navbar-bottom-container': <GrowiNavbarBottom />,
 
-  'grw-search-top': <SearchTop />,
-
-  'create-page-button-icon': <PageCreateButton isIcon />,
   'page-create-modal': <PageCreateModal />,
 
   'grw-sidebar-wrapper': <Sidebar />,
 
+  'grw-fab-container': <Fab />,
+
   'staff-credit': <StaffCredit />,
 };
 

+ 140 - 0
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -170,6 +170,146 @@ class OidcSecurityManagement extends React.Component {
               </div>
             </div>
 
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcAuthorizationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.authorization_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAuthorizationEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcAuthorizationEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcAuthorizationEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcTokenEndpoint" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.token_endpoint')}</label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcTokenEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcTokenEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcTokenEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcRevocationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.revocation_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcRevocationEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcRevocationEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcRevocationEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcIntrospectionEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.introspection_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcIntrospectionEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcIntrospectionEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcIntrospectionEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcUserInfoEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.userinfo_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcUserInfoEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcUserInfoEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcUserInfoEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcEndSessionEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.end_session_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcEndSessionEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcEndSessionEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcEndSessionEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcRegistrationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.registration_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcRegistrationEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcRegistrationEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcRegistrationEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcJWKSUri" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.jwks_uri')}</label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcJWKSUri"
+                  defaultValue={adminOidcSecurityContainer.state.oidcJWKSUri || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcJWKSUri(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
             <h3 className="alert-anchor border-bottom">
               Attribute Mapping ({t('security_setting.optional')})
             </h3>

+ 34 - 26
src/client/js/components/Admin/Users/PasswordResetModal.jsx

@@ -23,14 +23,14 @@ class PasswordResetModal extends React.Component {
   }
 
   async resetPassword() {
-    const { appContainer, userForPasswordResetModal } = this.props;
-
-    const res = await appContainer.apiPost('/admin/users.resetPassword', { user_id: userForPasswordResetModal._id });
-    if (res.ok) {
-      this.setState({ temporaryPassword: res.newPassword, isPasswordResetDone: true });
+    const { t, appContainer, userForPasswordResetModal } = this.props;
+    try {
+      const res = await appContainer.apiv3Put('/users/reset-password', { id: userForPasswordResetModal._id });
+      const { newPassword } = res.data;
+      this.setState({ temporaryPassword: newPassword, isPasswordResetDone: true });
     }
-    else {
-      toastError('Failed to reset password');
+    catch (err) {
+      toastError(err, t('toaster.failed_to_reset_password'));
     }
   }
 
@@ -38,15 +38,15 @@ class PasswordResetModal extends React.Component {
     const { t, userForPasswordResetModal } = this.props;
 
     return (
-      <div>
-        <p className="alert alert-danger">{t('admin:user_management.reset_password_modal.password_reset_message')}</p>
+      <>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('admin:user_management.reset_password_modal.password_never_seen')}<br />
+          <span className="text-danger">{t('admin:user_management.reset_password_modal.send_new_password')}</span>
         </p>
         <p>
-          {t('admin:user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
-      </div>
+      </>
     );
   }
 
@@ -54,26 +54,34 @@ class PasswordResetModal extends React.Component {
     const { t, userForPasswordResetModal } = this.props;
 
     return (
-      <div>
+      <>
+        <p className="alert alert-danger">{t('admin:user_management.reset_password_modal.password_reset_message')}</p>
         <p>
-          {t('admin:user_management.reset_password_modal.password_never_seen')}<br />
-          <span className="text-danger">{t('admin:user_management.reset_password_modal.send_new_password')}</span>
+          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('admin:user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
         </p>
-        <button type="submit" className="btn btn-primary" onClick={this.resetPassword}>
-          {t('admin:user_management.reset_password')}
-        </button>
-      </div>
+      </>
+    );
+  }
+
+  returnModalFooterBeforeReset() {
+    const { t } = this.props;
+    return (
+      <button type="submit" className="btn btn-danger" onClick={this.resetPassword}>
+        {t('admin:user_management.reset_password')}
+      </button>
     );
   }
 
-  returnModalFooter() {
+  returnModalFooterAfterReset() {
+    const { t } = this.props;
+
     return (
-      <div>
-        <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>OK</button>
-      </div>
+      <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>
+        {t('Close')}
+      </button>
     );
   }
 
@@ -87,10 +95,10 @@ class PasswordResetModal extends React.Component {
           {t('admin:user_management.reset_password') }
         </ModalHeader>
         <ModalBody>
-          {this.state.isPasswordResetDone ? this.renderModalBodyBeforeReset() : this.returnModalBodyAfterReset()}
+          {this.state.isPasswordResetDone ? this.returnModalBodyAfterReset() : this.renderModalBodyBeforeReset()}
         </ModalBody>
         <ModalFooter>
-          {this.state.isPasswordResetDone && this.returnModalFooter()}
+          {this.state.isPasswordResetDone ? this.returnModalFooterAfterReset() : this.returnModalFooterBeforeReset()}
         </ModalFooter>
       </Modal>
     );

+ 67 - 0
src/client/js/components/Fab.jsx

@@ -0,0 +1,67 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import StickyEvents from 'sticky-events';
+
+import NavigationContainer from '../services/NavigationContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+
+const logger = loggerFactory('growi:cli:Fab');
+
+const Fab = (props) => {
+  const { navigationContainer } = props;
+
+  const [animateClasses, setAnimateClasses] = useState('invisible');
+
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+
+    const classes = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+    setAnimateClasses(classes);
+  }, []);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
+
+
+  return (
+    <div className="grw-fab d-none d-md-block">
+      <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"
+          onClick={navigationContainer.openPageCreateModal}
+        >
+          <i className="icon-pencil"></i>
+        </button>
+      </div>
+      <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()}>
+          <i className="icon-control-start"></i>
+        </button>
+      </div>
+    </div>
+  );
+
+};
+
+Fab.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(Fab, [NavigationContainer]);

+ 15 - 11
src/client/js/components/Navbar/NavbarToggler.jsx → src/client/js/components/Navbar/DrawerToggler.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -6,24 +6,26 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '../../services/NavigationContainer';
 
-const NavbarToggler = (props) => {
+const DrawerToggler = (props) => {
 
   const { navigationContainer } = props;
 
-  const clickHandler = () => {
+  const clickHandler = useCallback(() => {
     navigationContainer.toggleDrawer();
-  };
+  }, [navigationContainer]);
+
+  const iconClass = props.iconClass || 'icon-menu';
 
   return (
-    <a
-      className="nav-link grw-navbar-toggler border-0 waves-effect waves-light"
+    <button
+      className="grw-drawer-toggler btn btn-secondary btn-xl"
       type="button"
       aria-expanded="false"
       aria-label="Toggle navigation"
       onClick={clickHandler}
     >
-      <i className="icon-menu"></i>
-    </a>
+      <i className={iconClass}></i>
+    </button>
   );
 
 };
@@ -31,12 +33,14 @@ const NavbarToggler = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const NavbarTogglerWrapper = withUnstatedContainers(NavbarToggler, [NavigationContainer]);
+const DrawerTogglerWrapper = withUnstatedContainers(DrawerToggler, [NavigationContainer]);
 
 
-NavbarToggler.propTypes = {
+DrawerToggler.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  iconClass: PropTypes.string,
 };
 
-export default withTranslation()(NavbarTogglerWrapper);
+export default withTranslation()(DrawerTogglerWrapper);

+ 10 - 30
src/client/js/components/Navbar/SearchTop.jsx → src/client/js/components/Navbar/GlobalSearch.jsx

@@ -9,7 +9,7 @@ import NavigationContainer from '../../services/NavigationContainer';
 import SearchForm from '../SearchForm';
 
 
-class SearchTop extends React.Component {
+class GlobalSearch extends React.Component {
 
   constructor(props) {
     super(props);
@@ -51,24 +51,8 @@ class SearchTop extends React.Component {
     window.location.href = url.href;
   }
 
-  Root = ({ children }) => {
-    const { isDeviceSmallerThanMd: isCollapsed } = this.props.navigationContainer.state;
-
-    return isCollapsed
-      ? (
-        <div id="grw-search-top-collapse" className="collapse bg-dark p-3">
-          {children}
-        </div>
-      )
-      : (
-        <div className="grw-search-top-absolute position-absolute">
-          {children}
-        </div>
-      );
-  };
-
-  SearchTopForm = () => {
-    const { t, appContainer } = this.props;
+  render() {
+    const { t, appContainer, dropup } = this.props;
     const scopeLabel = this.state.isScopeChildren
       ? t('header_search_box.label.This tree')
       : t('header_search_box.label.All pages');
@@ -79,7 +63,7 @@ class SearchTop extends React.Component {
     return (
       <div className={`form-group mb-0 d-print-none ${isReachable ? '' : 'has-error'}`}>
         <div className="input-group flex-nowrap">
-          <div className="input-group-prepend">
+          <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
             <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
               {scopeLabel}
             </button>
@@ -94,6 +78,7 @@ class SearchTop extends React.Component {
             onInputChange={this.onInputChange}
             onSubmit={this.search}
             placeholder="Search ..."
+            dropup={dropup}
           />
           <div className="btn-group-submit-search">
             <span className="btn-link text-decoration-none" onClick={this.search}>
@@ -105,24 +90,19 @@ class SearchTop extends React.Component {
     );
   }
 
-  render() {
-    const { Root, SearchTopForm } = this;
-    return (
-      <Root><SearchTopForm /></Root>
-    );
-  }
-
 }
 
-SearchTop.propTypes = {
+GlobalSearch.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  dropup: PropTypes.bool,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const SearchTopWrapper = withUnstatedContainers(SearchTop, [AppContainer, NavigationContainer]);
+const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer, NavigationContainer]);
 
-export default withTranslation()(SearchTopWrapper);
+export default withTranslation()(GlobalSearchWrapper);

+ 24 - 21
src/client/js/components/Navbar/GrowiNavbar.jsx

@@ -7,31 +7,31 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '../../services/NavigationContainer';
 import AppContainer from '../../services/AppContainer';
 
-import PageCreateButton from './PageCreateButton';
-import PersonalDropdown from './PersonalDropdown';
 import GrowiLogo from '../GrowiLogo';
 
+import PersonalDropdown from './PersonalDropdown';
+import GlobalSearch from './GlobalSearch';
+
 class GrowiNavbar extends React.Component {
 
   renderNavbarRight() {
-    const { appContainer } = this.props;
-    const isReachable = appContainer.config.isSearchServiceReachable;
+    const { t, appContainer, navigationContainer } = this.props;
+    const { currentUser } = appContainer;
+
+    // render login button
+    if (currentUser == null) {
+      return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+    }
 
     return (
       <>
         <li className="nav-item d-none d-md-block">
-          <PageCreateButton />
+          <button className="px-md-2 nav-link btn-create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
+            <i className="icon-pencil mr-2"></i>
+            <span className="d-none d-lg-block">{ t('New') }</span>
+          </button>
         </li>
 
-        {isReachable
-         && (
-         <li className="nav-item d-md-none">
-           <a type="button" className="nav-link px-4" data-target="#grw-search-top-collapse" data-toggle="collapse">
-             <i className="icon-magnifier mr-2"></i>
-           </a>
-         </li>
-         )}
-
         <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
           <PersonalDropdown />
         </li>
@@ -54,9 +54,9 @@ class GrowiNavbar extends React.Component {
   }
 
   render() {
-    const { appContainer } = this.props;
-    const { crowi } = appContainer.config;
-    const { currentUser } = appContainer;
+    const { appContainer, navigationContainer } = this.props;
+    const { crowi, isSearchServiceConfigured } = appContainer.config;
+    const { isDeviceSmallerThanMd } = navigationContainer.state;
 
     return (
       <>
@@ -68,9 +68,6 @@ class GrowiNavbar extends React.Component {
           </a>
         </div>
 
-        <ul className="navbar-nav d-md-none">
-          <li id="grw-navbar-toggler" className="nav-item"></li>
-        </ul>
         <div className="grw-app-title d-none d-md-block">
           {crowi.title}
         </div>
@@ -78,10 +75,16 @@ class GrowiNavbar extends React.Component {
 
         {/* Navbar Right  */}
         <ul className="navbar-nav ml-auto">
-          {currentUser != null ? this.renderNavbarRight() : <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>}
+          {this.renderNavbarRight()}
         </ul>
 
         {crowi.confidential != null && this.renderConfidential()}
+
+        { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+          <div className="grw-global-search grw-global-search-top position-absolute">
+            <GlobalSearch />
+          </div>
+        ) }
       </>
     );
   }

+ 61 - 0
src/client/js/components/Navbar/GrowiNavbarBottom.jsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import NavigationContainer from '../../services/NavigationContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import GlobalSearch from './GlobalSearch';
+
+const GrowiNavbarBottom = (props) => {
+
+  const {
+    navigationContainer,
+  } = props;
+  const { isDrawerOpened, isDeviceSmallerThanMd } = navigationContainer.state;
+
+  const additionalClasses = ['grw-navbar-bottom'];
+  if (isDrawerOpened) {
+    additionalClasses.push('grw-navbar-bottom-drawer-opened');
+  }
+
+  return (
+    <div className="d-md-none d-edit-none fixed-bottom">
+
+      { isDeviceSmallerThanMd && (
+        <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">
+          <div className="p-3">
+            <GlobalSearch dropup />
+          </div>
+        </div>
+      ) }
+
+      <div className={`navbar navbar-expand navbar-dark bg-primary px-0 ${additionalClasses.join(' ')}`}>
+
+        <ul className="navbar-nav w-100">
+          <li className="nav-item">
+            <a type="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">
+              <i className="icon-magnifier"></i>
+            </a>
+          </li>
+          <li className="nav-item">
+            <a type="button" className="nav-link btn-lg" onClick={() => navigationContainer.openPageCreateModal()}>
+              <i className="icon-pencil"></i>
+            </a>
+          </li>
+        </ul>
+      </div>
+
+    </div>
+  );
+};
+
+GrowiNavbarBottom.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(GrowiNavbarBottom, [NavigationContainer]);

+ 136 - 36
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -11,15 +11,19 @@ import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLi
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
+import PageContainer from '../../services/PageContainer';
 
 import RevisionPathControls from '../Page/RevisionPathControls';
-import PageContainer from '../../services/PageContainer';
 import TagLabels from '../Page/TagLabels';
 import LikeButton from '../LikeButton';
 import BookmarkButton from '../BookmarkButton';
 
 import PageCreator from './PageCreator';
 import RevisionAuthor from './RevisionAuthor';
+import DrawerToggler from './DrawerToggler';
+import UserPicture from '../User/UserPicture';
+
 
 // eslint-disable-next-line react/prop-types
 const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
@@ -57,64 +61,157 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
   );
 };
 
+// eslint-disable-next-line react/prop-types
+const UserPagePathNav = ({ pageId, pagePath }) => {
+  const linkedPagePath = new LinkedPagePath(pagePath);
+  const latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
+
+  return (
+    <div className="grw-page-path-nav">
+      <span className="d-flex align-items-center flex-wrap">
+        <h4 className="grw-user-page-path">{latterLink}</h4>
+        <RevisionPathControls
+          pageId={pageId}
+          pagePath={pagePath}
+        />
+      </span>
+    </div>
+  );
+};
+
+/* eslint-disable react/prop-types */
+const UserInfo = ({ pageUser }) => {
+  return (
+    <div className="grw-users-info d-flex align-items-center d-edit-none">
+      <UserPicture user={pageUser} />
+
+      <div className="users-meta">
+        <h1 className="user-page-name">
+          {pageUser.name}
+        </h1>
+        <div className="user-page-meta mt-1 mb-0">
+          <span className="user-page-username mr-2"><i className="icon-user mr-1"></i>{pageUser.username}</span>
+          <span className="user-page-email mr-2">
+            <i className="icon-envelope mr-1"></i>
+            {pageUser.isEmailPublished ? pageUser.email : '*****'}
+          </span>
+          {pageUser.introduction && <span className="user-page-introduction">{pageUser.introduction}</span>}
+        </div>
+      </div>
+
+    </div>
+  );
+};
+/* eslint-enable react/prop-types */
+
+/* eslint-disable react/prop-types */
+const PageReactionButtons = ({ appContainer, pageContainer }) => {
+
+  const { pageId, isLiked, pageUser } = pageContainer.state;
+
+  return (
+    <>
+      {pageUser == null && (
+      <span className="mr-2">
+        <LikeButton pageId={pageId} isLiked={isLiked} />
+      </span>
+      )}
+      <span className="mr-2">
+        <BookmarkButton pageId={pageId} crowi={appContainer} />
+      </span>
+    </>
+  );
+};
+/* eslint-enable react/prop-types */
+
 const GrowiSubNavigation = (props) => {
-  const isPageForbidden = document.querySelector('#grw-subnav').getAttribute('data-is-forbidden-page') === 'true';
-  const { appContainer, pageContainer } = props;
+  const {
+    appContainer, navigationContainer, pageContainer, isCompactMode,
+  } = props;
+  const { isDrawerMode } = navigationContainer.state;
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor,
+    isForbidden: isPageForbidden, pageUser,
   } = pageContainer.state;
 
   const isPageNotFound = pageId == null;
+  const isUserPage = pageUser != null;
   const isPageInTrash = isTrashPage(path);
 
   // Display only the RevisionPath
   if (isPageNotFound || isPageForbidden) {
     return (
-      <div className="px-3 py-3 grw-subnavbar">
+      <div className="grw-subnav d-flex align-items-center justify-content-between">
         <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
       </div>
     );
   }
 
-  const additionalClassNames = ['grw-subnavbar'];
-
   return (
-    <div className={`d-flex align-items-center justify-content-between px-3 py-1 ${additionalClassNames.join(' ')}`}>
+    <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact' : ''}`}>
 
-      {/* Page Path */}
-      <div>
-        <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
-        { !isPageNotFound && !isPageForbidden && (
-          <TagLabels />
+      {/* Left side */}
+      <div className="d-flex">
+        { isDrawerMode && (
+          <div className="d-none d-md-flex align-items-center border-right mr-3 pr-3">
+            <DrawerToggler />
+          </div>
         ) }
+
+        <div>
+          { !isCompactMode && !isPageNotFound && !isPageForbidden && !isUserPage && (
+            <div className="mb-2">
+              <TagLabels />
+            </div>
+          ) }
+
+          { isUserPage
+            ? (
+              <>
+                <UserPagePathNav pageId={pageId} pagePath={path} />
+                <UserInfo pageUser={pageUser} />
+              </>
+            )
+            : (
+              <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
+            )
+          }
+
+        </div>
       </div>
 
-      <div className="d-flex align-items-center">
-        { !isPageInTrash && (
-          /* Header Button */
-          <div className="mr-2">
-            <LikeButton pageId={pageId} isLiked={pageContainer.state.isLiked} />
-          </div>
-        ) }
-        { !isPageInTrash && (
-          <div>
-            <BookmarkButton pageId={pageId} crowi={appContainer} />
+      {/* Right side */}
+      <div className="d-flex">
+
+        <div className="d-flex flex-column align-items-end justify-content-center">
+          <div className="d-flex">
+            { !isPageInTrash && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
+            <div className="mt-2">
+              {/* TODO: impl View / Edit / HackMD button group */}
+              {/* <div className="btn-group" role="group" aria-label="Basic example">
+              <button type="button" className="btn btn-outline-primary">Left</button>
+              <button type="button" className="btn btn-outline-primary">Middle</button>
+              <button type="button" className="btn btn-outline-primary">Right</button>
+            </div> */}
+            </div>
           </div>
-        ) }
+        </div>
 
         {/* Page Authors */}
-        <ul className="authors text-nowrap d-none d-lg-block d-edit-none">
-          { creator != null && (
-            <li>
-              <PageCreator creator={creator} createdAt={createdAt} />
-            </li>
-          ) }
-          { revisionAuthor != null && (
-            <li className="mt-1">
-              <RevisionAuthor revisionAuthor={revisionAuthor} updatedAt={updatedAt} />
-            </li>
-          ) }
-        </ul>
+        { (!isCompactMode && !isUserPage) && (
+          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none">
+            { creator != null && (
+              <li className="pb-1">
+                <PageCreator creator={creator} createdAt={createdAt} />
+              </li>
+            ) }
+            { revisionAuthor != null && (
+              <li className="mt-1 pt-1 border-top">
+                <RevisionAuthor revisionAuthor={revisionAuthor} updatedAt={updatedAt} />
+              </li>
+            ) }
+          </ul>
+        ) }
       </div>
 
     </div>
@@ -125,13 +222,16 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
 
 
 GrowiSubNavigation.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
 };
 
 export default withTranslation()(GrowiSubNavigationWrapper);

+ 0 - 92
src/client/js/components/Navbar/GrowiSubNavigationForUserPage.jsx

@@ -1,92 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import LinkedPagePath from '@commons/models/linked-page-path';
-import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
-
-import RevisionPathControls from '../Page/RevisionPathControls';
-import BookmarkButton from '../BookmarkButton';
-import UserPicture from '../User/UserPicture';
-
-// eslint-disable-next-line react/prop-types
-const PagePathNav = ({ pageId, pagePath }) => {
-  const linkedPagePath = new LinkedPagePath(pagePath);
-  const latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-
-  return (
-    <div className="grw-page-path-nav">
-      <span className="d-flex align-items-center flex-wrap">
-        <h4 className="grw-user-page-path">{latterLink}</h4>
-        <RevisionPathControls
-          pageId={pageId}
-          pagePath={pagePath}
-        />
-      </span>
-    </div>
-  );
-};
-
-const GrowiSubNavigationForUserPage = (props) => {
-  const pageUser = JSON.parse(document.querySelector('#grw-subnav-for-user-page').getAttribute('data-page-user'));
-  const { appContainer, pageContainer } = props;
-  const {
-    pageId, path,
-  } = pageContainer.state;
-
-  const additionalClassNames = ['grw-subnavbar', 'grw-subnavbar-user-page'];
-  const layoutType = appContainer.getConfig().layoutType;
-
-  if (layoutType === 'growi') {
-    additionalClassNames.push('py-3');
-  }
-
-  return (
-    <div className={`px-3 py-3 ${additionalClassNames.join(' ')}`}>
-      <PagePathNav pageId={pageId} pagePath={path} />
-
-      <div className="d-flex align-items-center justify-content-between">
-
-        <div className="users-info d-flex align-items-center d-edit-none">
-          <UserPicture user={pageUser} />
-
-          <div className="users-meta">
-            <h1>
-              {pageUser.name}
-            </h1>
-            <ul className="user-page-meta mt-1 mb-0">
-              <li className="user-page-username"><i className="icon-user mr-1"></i>{pageUser.username}</li>
-              <li className="user-page-email">
-                <i className="icon-envelope mr-1"></i>
-                {pageUser.isEmailPublished ? pageUser.email : '*****'}
-              </li>
-              {pageUser.introduction && <li className="user-page-introduction"><p>{pageUser.introduction}</p></li>}
-            </ul>
-          </div>
-        </div>
-
-        <BookmarkButton pageId={pageId} crowi={appContainer} size="lg" />
-      </div>
-    </div>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiSubNavigationForUserPageWrapper = withUnstatedContainers(GrowiSubNavigationForUserPage, [AppContainer, PageContainer]);
-
-
-GrowiSubNavigationForUserPage.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withTranslation()(GrowiSubNavigationForUserPageWrapper);

+ 88 - 0
src/client/js/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -0,0 +1,88 @@
+import React, { useState, useEffect, useCallback } from 'react';
+// import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import StickyEvents from 'sticky-events';
+import { debounce } from 'throttle-debounce';
+
+import GrowiSubNavigation from './GrowiSubNavigation';
+
+const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
+
+
+/**
+ * Subnavigation
+ *
+ * needs:
+ *   #grw-subnav-fixed-container element
+ *   #grw-subnav-sticky-trigger element
+ *
+ * @param {object} props
+ */
+const GrowiSubNavigationSwitcher = (props) => {
+
+  const [isVisible, setVisible] = useState(false);
+
+  const resetWidth = useCallback(() => {
+    const elem = document.getElementById('grw-subnav-fixed-container');
+
+    if (elem == null || elem.parentNode == null) {
+      return;
+    }
+
+    // get parent width
+    const { clientWidth: width } = elem.parentNode;
+    // update style
+    elem.style.width = `${width}px`;
+  }, []);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = debounce(100, resetWidth);
+
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [resetWidth]);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+    setVisible(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
+
+  // update width
+  useEffect(() => {
+    resetWidth();
+  });
+
+  return (
+    <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
+      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed">
+        <GrowiSubNavigation isCompactMode />
+      </div>
+    </div>
+  );
+};
+
+GrowiSubNavigationSwitcher.propTypes = {
+};
+
+export default GrowiSubNavigationSwitcher;

+ 0 - 41
src/client/js/components/Navbar/PageCreateButton.jsx

@@ -1,41 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '../../services/NavigationContainer';
-
-const PageCreateButton = (props) => {
-  const { t, navigationContainer, isIcon } = props;
-
-  if (isIcon) {
-    return (
-      <button className="btn btn-lg btn-primary rounded-circle waves-effect waves-light" type="button" onClick={navigationContainer.openPageCreateModal}>
-        <i className="icon-pencil"></i>
-      </button>
-    );
-  }
-
-  return (
-    <button className="px-md-2 nav-link create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
-      <i className="icon-pencil mr-2"></i>
-      <span className="d-none d-lg-block">{ t('New') }</span>
-    </button>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageCreateButtonWrapper = withUnstatedContainers(PageCreateButton, [NavigationContainer]);
-
-
-PageCreateButton.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  isIcon: PropTypes.bool,
-};
-
-export default withTranslation()(PageCreateButtonWrapper);

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

@@ -9,7 +9,7 @@ const PageCreator = (props) => {
   const { creator, createdAt, isCompactMode } = props;
   const creatInfo = isCompactMode
     ? (<div>Created at <span className="text-muted">{createdAt}</span></div>)
-    : (<div><div>Created by <a href={userPageRoot(creator)}>{creator.name}</a></div><div className="text-muted">{createdAt}</div></div>);
+    : (<div><div>Created by <a href={userPageRoot(creator)}>{creator.name}</a></div><div className="text-muted text-date">{createdAt}</div></div>);
   const pictureSize = isCompactMode ? 'xs' : 'sm';
 
   return (

+ 6 - 1
src/client/js/components/Navbar/RevisionAuthor.jsx

@@ -9,7 +9,12 @@ const RevisionAuthor = (props) => {
   const { revisionAuthor, updatedAt, isCompactMode } = props;
   const updateInfo = isCompactMode
     ? (<div>Updated at <span className="text-muted">{updatedAt}</span></div>)
-    : (<div><div>Updated by  <a href={userPageRoot(revisionAuthor)}>{revisionAuthor.name}</a></div><div className="text-muted">{updatedAt}</div></div>);
+    : (
+      <div>
+        <div>Updated by <a href={userPageRoot(revisionAuthor)}>{revisionAuthor.name}</a></div>
+        <div className="text-muted text-date">{updatedAt}</div>
+      </div>
+    );
   const pictureSize = isCompactMode ? 'xs' : 'sm';
 
   return (

+ 69 - 0
src/client/js/components/Page/RenderTagLabels.jsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import PageContainer from '../../services/PageContainer';
+
+function RenderTagLabels(props) {
+  const { t, tags, pageContainer } = props;
+  const { pageId } = pageContainer;
+
+  function openEditorHandler() {
+    if (props.openEditorModal == null) {
+      return;
+    }
+    props.openEditorModal();
+  }
+
+  // activate suspense
+  if (tags == null) {
+    throw new Promise(() => {});
+  }
+
+  const isTagsEmpty = tags.length === 0;
+
+  const tagElements = tags.map((tag) => {
+    return (
+      <a key={`${pageId}_${tag}`} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
+        {tag}
+      </a>
+    );
+  });
+
+  return (
+    <>
+      {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>
+    </>
+  );
+
+}
+
+/**
+ * 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,
+
+};
+
+export default withTranslation()(RenderTagLabelsWrapper);

+ 62 - 0
src/client/js/components/Page/TagEditModal.jsx

@@ -0,0 +1,62 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import TagsInput from './TagsInput';
+
+function TagEditModal(props) {
+  const [tags, setTags] = useState([]);
+
+  function onTagsUpdatedByTagsInput(tags) {
+    setTags(tags);
+  }
+
+  useEffect(() => {
+    setTags(props.tags);
+  }, [props.tags]);
+
+  function closeModalHandler() {
+    if (props.onClose == null) {
+      return;
+    }
+    props.onClose();
+  }
+
+  function handleSubmit() {
+    if (props.onTagsUpdated == null) {
+      return;
+    }
+
+    props.onTagsUpdated(tags);
+    closeModalHandler();
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
+      <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
+          Edit Tags
+      </ModalHeader>
+      <ModalBody>
+        <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
+      </ModalBody>
+      <ModalFooter>
+        <Button color="primary" onClick={handleSubmit}>
+            Done
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+}
+
+TagEditModal.propTypes = {
+  tags: PropTypes.array,
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  onTagsUpdated: PropTypes.func,
+};
+
+export default TagEditModal;

+ 0 - 71
src/client/js/components/Page/TagEditor.jsx

@@ -1,71 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import AppContainer from '../../services/AppContainer';
-
-import TagsInput from './TagsInput';
-
-export default class TagEditor extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      tags: [],
-      isOpenModal: false,
-    };
-
-    this.show = this.show.bind(this);
-    this.onTagsUpdatedByTagsInput = this.onTagsUpdatedByTagsInput.bind(this);
-    this.closeModalHandler = this.closeModalHandler.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-  }
-
-  show(tags) {
-    this.setState({ tags, isOpenModal: true });
-  }
-
-  onTagsUpdatedByTagsInput(tags) {
-    this.setState({ tags });
-  }
-
-  closeModalHandler() {
-    this.setState({ isOpenModal: false });
-  }
-
-  async handleSubmit() {
-    this.props.onTagsUpdated(this.state.tags);
-
-    // close modal
-    this.setState({ isOpenModal: false });
-  }
-
-  render() {
-    return (
-      <Modal isOpen={this.state.isOpenModal} toggle={this.closeModalHandler} id="edit-tag-modal">
-        <ModalHeader tag="h4" toggle={this.closeModalHandler} className="bg-primary text-light">
-          Edit Tags
-        </ModalHeader>
-        <ModalBody>
-          <TagsInput tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByTagsInput} />
-        </ModalBody>
-        <ModalFooter>
-          <Button color="primary" onClick={this.handleSubmit}>
-            Done
-          </Button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-TagEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  onTagsUpdated: PropTypes.func.isRequired,
-};

+ 63 - 92
src/client/js/components/Page/TagLabels.jsx

@@ -1,16 +1,16 @@
-import React from 'react';
+import React, { Suspense } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import * as toastr from 'toastr';
+import { toastSuccess, toastError } from '../../util/apiNotification';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
-import NavigationContainer from '../../services/NavigationContainer';
 import PageContainer from '../../services/PageContainer';
 import EditorContainer from '../../services/EditorContainer';
 
-import TagEditor from './TagEditor';
+import RenderTagLabels from './RenderTagLabels';
+import TagEditModal from './TagEditModal';
 
 class TagLabels extends React.Component {
 
@@ -18,118 +18,84 @@ class TagLabels extends React.Component {
     super(props);
 
     this.state = {
-      showTagEditor: false,
+      isTagEditModalShown: false,
     };
 
-    this.showEditor = this.showEditor.bind(this);
+    this.openEditorModal = this.openEditorModal.bind(this);
+    this.closeEditorModal = this.closeEditorModal.bind(this);
     this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
   }
 
   /**
    * @return tags data
-   *   1. pageContainer.state.tags if editorMode is null
-   *   2. editorContainer.state.tags if editorMode is not null
+   *   1. pageContainer.state.tags if isEditorMode is false
+   *   2. editorContainer.state.tags if isEditorMode is true
    */
   getEditTargetData() {
-    const { editorMode } = this.props.navigationContainer.state;
-    return (editorMode == null)
-      ? this.props.pageContainer.state.tags
-      : this.props.editorContainer.state.tags;
+    const { isEditorMode } = this.props;
+    return (isEditorMode) ? this.props.editorContainer.state.tags : this.props.pageContainer.state.tags;
   }
 
-  showEditor() {
-    this.tagEditor.show(this.getEditTargetData());
+  openEditorModal() {
+    this.setState({ isTagEditModalShown: true });
+  }
+
+  closeEditorModal() {
+    this.setState({ isTagEditModalShown: false });
   }
 
   async tagsUpdatedHandler(tags) {
-    const { appContainer, navigationContainer, editorContainer } = this.props;
-    const { editorMode } = navigationContainer.state;
+    const { appContainer, editorContainer, isEditorMode } = this.props;
 
-    // post api request and update tags
-    if (editorMode == null) {
-      const { pageContainer } = this.props;
-
-      try {
-        const { pageId } = pageContainer.state;
-        await appContainer.apiPost('/tags.update', { pageId, tags });
-
-        // update pageContainer.state
-        pageContainer.setState({ tags });
-        editorContainer.setState({ tags });
-
-        this.apiSuccessHandler();
-      }
-      catch (err) {
-        this.apiErrorHandler(err);
-        return;
-      }
-    }
     // only update tags in editorContainer
-    else {
-      editorContainer.setState({ tags });
+    if (isEditorMode) {
+      return editorContainer.setState({ tags });
     }
-  }
 
-  apiSuccessHandler() {
-    toastr.success(undefined, 'updated tags successfully', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '1200',
-      extendedTimeOut: '150',
-    });
-  }
+    // post api request and update tags
+    const { pageContainer } = this.props;
+
+    try {
+      const { pageId } = pageContainer.state;
+      await appContainer.apiPost('/tags.update', { pageId, tags });
+
+      // update pageContainer.state
+      pageContainer.setState({ tags });
+      editorContainer.setState({ tags });
 
-  apiErrorHandler(err) {
-    toastr.error(err.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
   }
 
-  render() {
-    const { t } = this.props;
-    const { pageId } = this.props.pageContainer.state;
 
+  render() {
     const tags = this.getEditTargetData();
 
-    const tagElements = tags.map((tag) => {
-      return (
-        <span key={`${pageId}_${tag}`} className="text-muted">
-          <i className="tag-icon icon-tag mr-1"></i>
-          <a className="tag-name mr-2" href={`/_search?q=tag:${tag}`} key={`${pageId}_${tag}_link`}>{tag}</a>
-        </span>
-      );
-    });
-
     return (
-      <div className="tag-labels">
-        {tags.length === 0 && (
-          <a className="btn btn-link btn-edit-tags no-tags p-0 text-muted" onClick={this.showEditor}>
-            { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
-          </a>
-        )}
-        {tagElements}
-        {tags.length > 0 && (
-          <a className="btn btn-link btn-edit-tags p-0 text-muted" onClick={this.showEditor}>
-            <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
-          </a>
-        )}
-
-        <TagEditor
-          ref={(c) => { this.tagEditor = c }}
+      <>
+
+        <form className="grw-tag-labels form-inline">
+          <i className="tag-icon icon-tag mr-2"></i>
+          <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
+            <RenderTagLabels
+              tags={tags}
+              openEditorModal={this.openEditorModal}
+            />
+          </Suspense>
+        </form>
+
+        <TagEditModal
+          tags={tags}
+          isOpen={this.state.isTagEditModalShown}
+          onClose={this.closeEditorModal}
           appContainer={this.props.appContainer}
-          show={this.state.showTagEditor}
           onTagsUpdated={this.tagsUpdatedHandler}
-        >
-        </TagEditor>
-      </div>
+        />
+
+      </>
     );
   }
 
@@ -138,15 +104,20 @@ class TagLabels extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, NavigationContainer, PageContainer, EditorContainer]);
-
+const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, PageContainer, EditorContainer]);
 
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isEditorMode: PropTypes.bool,
+};
+
+TagLabels.defaultProps = {
+  isEditorMode: false,
 };
 
 export default withTranslation()(TagLabelsWrapper);

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

@@ -307,7 +307,7 @@ class PageEditor extends React.Component {
             onSave={this.onSaveWithShortcut}
           />
         </div>
-        <div className="d-none d-xl-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
+        <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
           <Preview
             markdown={this.state.markdown}
             // eslint-disable-next-line no-return-assign

+ 78 - 0
src/client/js/components/PageEditor/EditorNavbarBottom.jsx

@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { Collapse } from 'reactstrap';
+
+import NavigationContainer from '../../services/NavigationContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import SavePageControls from '../SavePageControls';
+
+import OptionsSelector from './OptionsSelector';
+
+const EditorNavbarBottom = (props) => {
+
+  const [isExpanded, setExpanded] = useState(false);
+
+  const {
+    navigationContainer,
+  } = props;
+  const { editorMode, isDrawerMode, isDeviceSmallerThanMd } = navigationContainer.state;
+
+  const additionalClasses = ['grw-editor-navbar-bottom'];
+
+  const renderDrawerButton = () => (
+    <button type="button" className="btn btn-outline-secondary border-0" onClick={() => navigationContainer.toggleDrawer()}>
+      <i className="icon-menu"></i>
+    </button>
+  );
+
+  // eslint-disable-next-line react/prop-types
+  const renderExpandButton = () => (
+    <div className="d-md-none ml-2">
+      <button
+        type="button"
+        className={`btn btn-outline-secondary btn-expand border-0 ${isExpanded ? 'expand' : ''}`}
+        onClick={() => setExpanded(!isExpanded)}
+      >
+        <i className="icon-arrow-up"></i>
+      </button>
+    </div>
+  );
+
+  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
+
+  return (
+    <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
+      <div className={`navbar navbar-expand border-top px-2 ${additionalClasses.join(' ')}`}>
+        <form className="form-inline">
+          { isDrawerMode && renderDrawerButton() }
+          { isOptionsSelectorEnabled && !isDeviceSmallerThanMd && <OptionsSelector /> }
+        </form>
+        <form className="form-inline ml-auto">
+          <SavePageControls />
+          { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
+        </form>
+      </div>
+      {/* Collapsed OptionsSelector */}
+      { isCollapsedOptionsSelectorEnabled && (
+        <Collapse isOpen={isExpanded}>
+          <div className="px-2"> {/* set padding for border-top */}
+            <div className={`navbar navbar-expand border-top px-0 ${additionalClasses.join(' ')}`}>
+              <form className="form-inline ml-auto">
+                <OptionsSelector />
+              </form>
+            </div>
+          </div>
+        </Collapse>
+      ) }
+    </div>
+  );
+};
+
+EditorNavbarBottom.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer]);

+ 40 - 19
src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -8,6 +8,7 @@ import {
 } from 'reactstrap';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
 import EditorContainer from '../../services/EditorContainer';
 
 
@@ -26,7 +27,7 @@ class OptionsSelector extends React.Component {
   constructor(props) {
     super(props);
 
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
@@ -109,10 +110,19 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="my-0 form-group">
-        <label className="mr-2">Theme:</label>
-        <div className="btn-group btn-group-sm dropup">
-          <button className="btn btn-outline-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <div className="input-group">
+        <div className="input-group-prepend">
+          <span className="input-group-text" id="igt-theme">Theme</span>
+        </div>
+        <div className="input-group-append dropup">
+          <button
+            type="button"
+            className="btn btn-outline-secondary dropdown-toggle"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="false"
+            aria-describedby="igt-theme"
+          >
             {selectedTheme}
           </button>
           <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
@@ -136,10 +146,19 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="my-0 form-group">
-        <label className="mr-2">Keymap:</label>
-        <div className="btn-group btn-group-sm dropup">
-          <button className="btn btn-outline-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <div className="input-group">
+        <div className="input-group-prepend">
+          <span className="input-group-text" id="igt-keymap">Keymap</span>
+        </div>
+        <div className="input-group-append dropup">
+          <button
+            type="button"
+            className="btn btn-outline-secondary dropdown-toggle"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="false"
+            aria-describedby="igt-keymap"
+          >
             {selectedKeymapMode}
           </button>
           <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
@@ -156,7 +175,6 @@ class OptionsSelector extends React.Component {
 
         <Dropdown
           direction="up"
-          size="sm"
           className="grw-editor-configuration-dropdown"
           isOpen={this.state.isCddMenuOpened}
           toggle={this.onToggleConfigurationDropdown}
@@ -190,9 +208,11 @@ class OptionsSelector extends React.Component {
 
     return (
       <DropdownItem toggle={false} onClick={this.onClickStyleActiveLine}>
-        <span className="icon-container"></span>
-        <span className="menuitem-label mr-2">{ t('page_edit.Show active line') }</span>
-        <span className="icon-container"><i className={iconClassName}></i></span>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">{ t('page_edit.Show active line') }</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
       </DropdownItem>
     );
   }
@@ -215,9 +235,11 @@ class OptionsSelector extends React.Component {
 
     return (
       <DropdownItem toggle={false} onClick={this.onClickRenderMathJaxInRealtime}>
-        <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
-        <span className="menuitem-label">MathJax Rendering</span>
-        <i className={iconClassName}></i>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
+          <span className="menuitem-label">MathJax Rendering</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
       </DropdownItem>
     );
   }
@@ -237,14 +259,13 @@ class OptionsSelector extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [EditorContainer]);
+const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer, EditorContainer]);
 
 OptionsSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  crowi: PropTypes.object.isRequired,
 };
 
 export default withTranslation()(OptionsSelectorWrapper);

+ 2 - 2
src/client/js/components/PageEditor/PagePathNavForEditor.jsx

@@ -20,7 +20,7 @@ const PagePathNavForEditor = (props) => {
   const pagePathHierarchicalLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
 
   return (
-    <div className="grw-page-path-nav-for-edit mt-1">
+    <div className="grw-page-path-nav-for-edit">
       <span className="d-flex align-items-center flex-wrap">
         <h3 className="mb-0 grw-page-path-link">{pagePathHierarchicalLink}</h3>
         <RevisionPathControls
@@ -28,7 +28,7 @@ const PagePathNavForEditor = (props) => {
           pagePath={path}
         />
       </span>
-      <TagLabels />
+      <TagLabels isEditorMode />
     </div>
   );
 };

+ 1 - 3
src/client/js/components/PageManagement/ApiErrorMessage.jsx

@@ -42,12 +42,10 @@ const ApiErrorMessage = (props) => {
         return (
           <strong><i className="icon-fw icon-ban"></i> Invalid path</strong>
         );
-      case 'unknown':
+      default:
         return (
           <strong><i className="icon-fw icon-ban"></i> Unknown error occured</strong>
         );
-      default:
-        return null;
     }
   }
 

+ 56 - 46
src/client/js/components/PageStatusAlert.jsx

@@ -25,73 +25,64 @@ class PageStatusAlert extends React.Component {
     this.state = {
     };
 
-    this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
-    this.renderDraftExistsAlert = this.renderDraftExistsAlert.bind(this);
-    this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
-  }
-
-  componentWillMount() {
-    this.props.appContainer.registerComponentInstance('PageStatusAlert', this);
+    this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
+    this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
   }
 
   refreshPage() {
     window.location.reload();
   }
 
-  renderSomeoneEditingAlert() {
+  getContentsForSomeoneEditingAlert() {
     const { t } = this.props;
-    return (
-      <div className="alert-hackmd-someone-editing alert alert-success fixed-bottom p-3 mb-0">
+    return [
+      ['bg-success', 'd-hackmd-none'],
+      <>
         <i className="icon-fw icon-people"></i>
         {t('hackmd.someone_editing')}
-        &nbsp;
-        <i className="fa fa-angle-double-right"></i>
-        &nbsp;
-        <a href="#hackmd" className="font-weight-bold text-decoration-none">
-          <u>Open HackMD Editor</u>
-        </a>
-      </div>
-    );
+      </>,
+      <a href="#hackmd" className="btn btn-outline-white">
+        <i className="fa fa-fw fa-file-text-o mr-1"></i>
+        Open HackMD Editor
+      </a>,
+    ];
   }
 
-  renderDraftExistsAlert(isRealtime) {
+  getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
-    return (
-      <div className="alert-hackmd-draft-exists alert alert-success fixed-bottom p-3 mb-0">
+    return [
+      ['bg-success', 'd-hackmd-none'],
+      <>
         <i className="icon-fw icon-pencil"></i>
         {t('hackmd.this_page_has_draft')}
-        &nbsp;
-        <i className="fa fa-angle-double-right"></i>
-        &nbsp;
-        <a href="#hackmd" className="font-weight-bold text-decoration-none">
-          <u>Open HackMD Editor</u>
-        </a>
-      </div>
-    );
+      </>,
+      <a href="#hackmd" className="btn btn-outline-white">
+        <i className="fa fa-fw fa-file-text-o mr-1"></i>
+        Open HackMD Editor
+      </a>,
+    ];
   }
 
-  renderUpdatedAlert() {
+  getContentsForUpdatedAlert() {
     const { t } = this.props;
     const label1 = t('edited this page');
     const label2 = t('Load latest');
 
-    return (
-      <div className="alert alert-warning fixed-bottom p-3 mb-0">
+    return [
+      ['bg-warning'],
+      <>
         <i className="icon-fw icon-bulb"></i>
         {this.props.pageContainer.state.lastUpdateUsername} {label1}
-        &nbsp;
-        <i className="fa fa-angle-double-right"></i>
-        &nbsp;
-        <a href="#" onClick={this.refreshPage} className="font-weight-bold text-decoration-none">
-          <u>{label2}</u>
-        </a>
-      </div>
-    );
+      </>,
+      <a href="#" className="btn btn-outline-white" onClick={this.refreshPage}>
+        <i className="icon-fw icon-reload mr-1"></i>
+        {label2}
+      </a>,
+    ];
   }
 
   render() {
-    let content = <React.Fragment></React.Fragment>;
-
     const {
       revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
     } = this.props.pageContainer.state;
@@ -99,20 +90,39 @@ class PageStatusAlert extends React.Component {
     const isRevisionOutdated = revisionId !== remoteRevisionId;
     const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
+    let getContentsFunc = null;
+
     // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
-      content = this.renderUpdatedAlert();
+      getContentsFunc = this.getContentsFunc;
     }
     // when someone editing with HackMD
     else if (isHackmdDraftUpdatingInRealtime) {
-      content = this.renderSomeoneEditingAlert();
+      getContentsFunc = this.getContentsForSomeoneEditingAlert;
     }
     // when the draft of HackMD is newest
     else if (hasDraftOnHackmd) {
-      content = this.renderDraftExistsAlert();
+      getContentsFunc = this.getContentsForDraftExistsAlert;
+    }
+    // do not render anything
+    else {
+      return null;
     }
 
-    return content;
+    const [additionalClasses, label, btn] = getContentsFunc();
+
+    return (
+      <div className={`card grw-page-status-alert text-white fixed-bottom animated fadeInUp faster ${additionalClasses.join(' ')}`}>
+        <div className="card-body">
+          <p className="card-text grw-card-label-container">
+            {label}
+          </p>
+          <p className="card-text grw-card-btn-container">
+            {btn}
+          </p>
+        </div>
+      </div>
+    );
   }
 
 }

+ 9 - 3
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -146,7 +146,8 @@ class GrantSelector extends React.Component {
 
       const labelElm = (
         <span>
-          <i className={`icon icon-fw ${opt.iconClass}`}></i> {t(label)}
+          <i className={`icon icon-fw ${opt.iconClass}`}></i>
+          <span className="label">{t(label)}</span>
         </span>
       );
 
@@ -161,7 +162,12 @@ class GrantSelector extends React.Component {
 
     // add specified group option
     if (grantGroup != null) {
-      const labelElm = <span><i className="icon icon-fw icon-organization"></i> {this.getGroupName()}</span>;
+      const labelElm = (
+        <span>
+          <i className="icon icon-fw icon-organization"></i>
+          <span className="label">{this.getGroupName()}</span>
+        </span>
+      );
 
       // set dropdownToggleLabelElm
       dropdownToggleLabelElm = labelElm;
@@ -171,7 +177,7 @@ class GrantSelector extends React.Component {
 
     return (
       <div className="form-group grw-grant-selector mb-0">
-        <UncontrolledDropdown direction="up" size="sm">
+        <UncontrolledDropdown direction="up">
           <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={this.props.disabled}>
             {dropdownToggleLabelElm}
           </DropdownToggle>

+ 3 - 1
src/client/js/components/SearchForm.jsx

@@ -101,7 +101,7 @@ class SearchForm extends React.Component {
   }
 
   render() {
-    const { t, appContainer } = this.props;
+    const { t, appContainer, dropup } = this.props;
 
     const config = appContainer.getConfig();
     const isReachable = config.isSearchServiceReachable;
@@ -115,6 +115,7 @@ class SearchForm extends React.Component {
 
     return (
       <SearchTypeahead
+        dropup={dropup}
         onChange={this.onChange}
         onSubmit={this.props.onSubmit}
         onInputChange={this.props.onInputChange}
@@ -138,6 +139,7 @@ SearchForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
+  dropup: PropTypes.bool,
   keyword: PropTypes.string,
   onSubmit: PropTypes.func.isRequired,
   onInputChange: PropTypes.func,

+ 9 - 4
src/client/js/components/Sidebar.jsx

@@ -12,11 +12,13 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
 import NavigationContainer from '../services/NavigationContainer';
 
+import DrawerToggler from './Navbar/DrawerToggler';
+
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarContents from './Sidebar/SidebarContents';
 import StickyStretchableScroller from './StickyStretchableScroller';
 
-const sidebarDefaultWidth = 240;
+const sidebarDefaultWidth = 320;
 
 class Sidebar extends React.Component {
 
@@ -118,7 +120,7 @@ class Sidebar extends React.Component {
 
   backdropClickedHandler = () => {
     const { navigationContainer } = this.props;
-    navigationContainer.setState({ isDrawerOpened: false });
+    navigationContainer.toggleDrawer();
   }
 
   itemSelectedHandler = (contentsId) => {
@@ -145,7 +147,8 @@ class Sidebar extends React.Component {
   );
 
   renderSidebarContents = () => {
-    const scrollTargetSelector = 'div[data-testid="ContextualNavigation"] div[role="group"]';
+    // const scrollTargetSelector = 'div[data-testid="ContextualNavigation"] div[role="group"]';
+    const scrollTargetSelector = '#grw-sidebar-content-container';
 
     return (
       <>
@@ -158,6 +161,8 @@ class Sidebar extends React.Component {
         <div id="grw-sidebar-content-container" className="grw-sidebar-content-container">
           <SidebarContents />
         </div>
+
+        <DrawerToggler iconClass="icon-arrow-left" />
       </>
     );
   };
@@ -181,7 +186,7 @@ class Sidebar extends React.Component {
               experimental_hideNavVisuallyOnCollapse
               experimental_flyoutOnHover
               experimental_alternateFlyoutBehaviour
-              // experimental_fullWidthFlyout
+              experimental_fullWidthFlyout
               shouldHideGlobalNavShadow
               showContextualNavigation
             >

+ 16 - 13
src/client/js/components/TableOfContents.jsx

@@ -25,27 +25,30 @@ const TableOfContents = (props) => {
     const containerElem = document.querySelector('#revision-toc');
     const containerTop = containerElem.getBoundingClientRect().top;
 
-    // window height - revisionToc top - .system-version height
-    return window.innerHeight - containerTop - 20;
+    // window height - revisionToc top - .system-version - .grw-fab-container height
+    return window.innerHeight - containerTop - 20 - 155;
   }, []);
 
   const { tocHtml } = pageContainer.state;
 
   return (
-    <StickyStretchableScroller
-      contentsElemSelector=".revision-toc .markdownIt-TOC"
-      stickyElemSelector="#revision-toc"
-      calcViewHeightFunc={calcViewHeight}
-    >
-      <div
-        id="revision-toc-content"
-        className="revision-toc-content"
+    <>
+      {/* TODO GW-3253 add four contents */}
+      <StickyStretchableScroller
+        contentsElemSelector=".revision-toc .markdownIt-TOC"
+        stickyElemSelector="#revision-toc"
+        calcViewHeightFunc={calcViewHeight}
+      >
+        <div
+          id="revision-toc-content"
+          className="revision-toc-content"
         // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{
+          dangerouslySetInnerHTML={{
           __html: tocHtml,
         }}
-      />
-    </StickyStretchableScroller>
+        />
+      </StickyStretchableScroller>
+    </>
   );
 
 };

+ 106 - 2
src/client/js/services/AdminOidcSecurityContainer.js

@@ -23,6 +23,14 @@ export default class AdminOidcSecurityContainer extends Container {
       callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/oidc/callback'),
       oidcProviderName: '',
       oidcIssuerHost: '',
+      oidcAuthorizationEndpoint: '',
+      oidcTokenEndpoint: '',
+      oidcRevocationEndpoint: '',
+      oidcIntrospectionEndpoint: '',
+      oidcUserInfoEndpoint: '',
+      oidcEndSessionEndpoint: '',
+      oidcRegistrationEndpoint: '',
+      oidcJWKSUri: '',
       oidcClientId: '',
       oidcClientSecret: '',
       oidcAttrMapId: '',
@@ -45,6 +53,14 @@ export default class AdminOidcSecurityContainer extends Container {
       this.setState({
         oidcProviderName: oidcAuth.oidcProviderName,
         oidcIssuerHost: oidcAuth.oidcIssuerHost,
+        oidcAuthorizationEndpoint: oidcAuth.oidcAuthorizationEndpoint,
+        oidcTokenEndpoint: oidcAuth.oidcTokenEndpoint,
+        oidcRevocationEndpoint: oidcAuth.oidcRevocationEndpoint,
+        oidcIntrospectionEndpoint: oidcAuth.oidcIntrospectionEndpoint,
+        oidcUserInfoEndpoint: oidcAuth.oidcUserInfoEndpoint,
+        oidcEndSessionEndpoint: oidcAuth.oidcEndSessionEndpoint,
+        oidcRegistrationEndpoint: oidcAuth.oidcRegistrationEndpoint,
+        oidcJWKSUri: oidcAuth.oidcJWKSUri,
         oidcClientId: oidcAuth.oidcClientId,
         oidcClientSecret: oidcAuth.oidcClientSecret,
         oidcAttrMapId: oidcAuth.oidcAttrMapId,
@@ -83,6 +99,62 @@ export default class AdminOidcSecurityContainer extends Container {
     this.setState({ oidcIssuerHost: inputValue });
   }
 
+  /**
+   * Change oidcAuthorizationEndpoint
+   */
+  changeOidcAuthorizationEndpoint(inputValue) {
+    this.setState({ oidcAuthorizationEndpoint: inputValue });
+  }
+
+  /**
+   * Change oidcTokenEndpoint
+   */
+  changeOidcTokenEndpoint(inputValue) {
+    this.setState({ oidcTokenEndpoint: inputValue });
+  }
+
+  /**
+   * Change oidcRevocationEndpoint
+   */
+  changeOidcRevocationEndpoint(inputValue) {
+    this.setState({ oidcRevocationEndpoint: inputValue });
+  }
+
+  /**
+   * Change oidcIntrospectionEndpoint
+   */
+  changeOidcIntrospectionEndpoint(inputValue) {
+    this.setState({ oidcIntrospectionEndpoint: inputValue });
+  }
+
+  /**
+   * Change oidcUserInfoEndpoint
+   */
+  changeOidcUserInfoEndpoint(inputValue) {
+    this.setState({ oidcUserInfoEndpoint: inputValue });
+  }
+
+  /**
+   * Change oidcEndSessionEndpoint
+   */
+  changeOidcEndSessionEndpoint(inputValue) {
+    this.setState({ oidcEndSessionEndpoint: inputValue });
+  }
+
+  /**
+   * Change oidcRegistrationEndpoint
+   */
+  changeOidcRegistrationEndpoint(inputValue) {
+    this.setState({ oidcRegistrationEndpoint: inputValue });
+  }
+
+  /**
+   * Change oidcJWKSUri
+   */
+  changeOidcJWKSUri(inputValue) {
+    this.setState({ oidcJWKSUri: inputValue });
+  }
+
   /**
    * Change oidcClientId
    */
@@ -144,13 +216,37 @@ export default class AdminOidcSecurityContainer extends Container {
    */
   async updateOidcSetting() {
     const {
-      oidcProviderName, oidcIssuerHost, oidcClientId, oidcClientSecret, oidcAttrMapId, oidcAttrMapUserName,
-      oidcAttrMapName, oidcAttrMapEmail, isSameUsernameTreatedAsIdenticalUser, isSameEmailTreatedAsIdenticalUser,
+      oidcProviderName,
+      oidcIssuerHost,
+      oidcAuthorizationEndpoint,
+      oidcTokenEndpoint,
+      oidcRevocationEndpoint,
+      oidcIntrospectionEndpoint,
+      oidcUserInfoEndpoint,
+      oidcEndSessionEndpoint,
+      oidcRegistrationEndpoint,
+      oidcJWKSUri,
+      oidcClientId,
+      oidcClientSecret,
+      oidcAttrMapId,
+      oidcAttrMapUserName,
+      oidcAttrMapName,
+      oidcAttrMapEmail,
+      isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser,
     } = this.state;
 
     let requestParams = {
       oidcProviderName,
       oidcIssuerHost,
+      oidcAuthorizationEndpoint,
+      oidcTokenEndpoint,
+      oidcRevocationEndpoint,
+      oidcIntrospectionEndpoint,
+      oidcUserInfoEndpoint,
+      oidcEndSessionEndpoint,
+      oidcRegistrationEndpoint,
+      oidcJWKSUri,
       oidcClientId,
       oidcClientSecret,
       oidcAttrMapId,
@@ -168,6 +264,14 @@ export default class AdminOidcSecurityContainer extends Container {
     this.setState({
       oidcProviderName: securitySettingParams.oidcProviderName,
       oidcIssuerHost: securitySettingParams.oidcIssuerHost,
+      oidcAuthorizationEndpoint: securitySettingParams.oidcAuthorizationEndpoint,
+      oidcTokenEndpoint: securitySettingParams.oidcTokenEndpoint,
+      oidcRevocationEndpoint: securitySettingParams.oidcRevocationEndpoint,
+      oidcIntrospectionEndpoint: securitySettingParams.oidcIntrospectionEndpoint,
+      oidcUserInfoEndpoint: securitySettingParams.oidcUserInfoEndpoint,
+      oidcEndSessionEndpoint: securitySettingParams.oidcEndSessionEndpoint,
+      oidcRegistrationEndpoint: securitySettingParams.oidcRegistrationEndpoint,
+      oidcJWKSUri: securitySettingParams.oidcJWKSUri,
       oidcClientId: securitySettingParams.oidcClientId,
       oidcClientSecret: securitySettingParams.oidcClientSecret,
       oidcAttrMapId: securitySettingParams.oidcAttrMapId,

+ 12 - 9
src/client/js/services/EditorContainer.js

@@ -24,7 +24,7 @@ export default class EditorContainer extends Container {
     }
 
     this.state = {
-      tags: [],
+      tags: null,
 
       isSlackEnabled: false,
       slackChannels: mainContent.getAttribute('data-slack-channels') || '',
@@ -57,16 +57,19 @@ export default class EditorContainer extends Container {
    * initialize state for page permission
    */
   initStateGrant() {
-    const elem = document.getElementById('save-page-controls');
+    const mainContent = document.getElementById('content-main');
 
-    if (elem) {
-      this.state.grant = +elem.dataset.grant;
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
 
-      const grantGroupId = elem.dataset.grantGroup;
-      if (grantGroupId != null && grantGroupId.length > 0) {
-        this.state.grantGroupId = grantGroupId;
-        this.state.grantGroupName = elem.dataset.grantGroupName;
-      }
+    this.state.grant = +mainContent.getAttribute('data-page-grant');
+
+    const grantGroupId = mainContent.getAttribute('data-page-grant-group');
+    if (grantGroupId != null && grantGroupId.length > 0) {
+      this.state.grantGroupId = grantGroupId;
+      this.state.grantGroupName = mainContent.getAttribute('data-page-grant-group-name');
     }
   }
 

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

@@ -5,7 +5,7 @@ import { Container } from 'unstated';
  * @extends {Container} unstated Container
  */
 
-const scrollThresForThrottling = 100;
+const SCROLL_THRES_SKIP = 200;
 
 export default class NavigationContainer extends Container {
 
@@ -93,7 +93,7 @@ export default class NavigationContainer extends Container {
       const currentYOffset = window.pageYOffset;
 
       // original throttling
-      if (scrollThresForThrottling < currentYOffset) {
+      if (SCROLL_THRES_SKIP < currentYOffset) {
         return;
       }
 
@@ -171,4 +171,18 @@ export default class NavigationContainer extends Container {
     this.setState({ isPageCreateModalShown: false });
   }
 
+  smoothScrollIntoView(element = null, offsetTop = 0) {
+    const targetElement = element || window.document.body;
+
+    // get the distance to the target element top
+    const rectTop = targetElement.getBoundingClientRect().top;
+
+    const top = window.pageYOffset + rectTop - offsetTop;
+
+    window.scrollTo({
+      top,
+      behavior: 'smooth',
+    });
+  }
+
 }

+ 3 - 1
src/client/js/services/PageContainer.js

@@ -56,10 +56,12 @@ export default class PageContainer extends Container {
       createdAt: mainContent.getAttribute('data-page-created-at'),
       creator: JSON.parse(mainContent.getAttribute('data-page-creator')),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
+      isForbidden:  JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
       isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeletable:  JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isAbleToDeleteCompletely:  JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
-      tags: [],
+      pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
+      tags: null,
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
 

+ 9 - 4
src/client/styles/scss/_admin.scss

@@ -1,4 +1,13 @@
 .admin-page {
+  .title {
+    padding: 0.5rem 15px;
+
+    line-height: 1em;
+
+    @include variable-font-size(28px);
+    line-height: 1.1em;
+  }
+
   .admin-user-menu {
     .dropdown-menu {
       right: 0;
@@ -160,10 +169,6 @@
       background-color: rgba($info, 0.1);
     }
   }
-
-  .grw-fixed-controls-container {
-    display: none;
-  }
 }
 
 .admin-navigation {

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

@@ -19,6 +19,15 @@ body {
   border-bottom: 1px solid $gray-500;
 }
 
+// padding settings for GrowiNavbarBottom
+.page-wrapper {
+  padding-bottom: $grw-navbar-bottom-height;
+
+  @include media-breakpoint-up(md) {
+    padding-bottom: unset;
+  }
+}
+
 .main {
   margin-top: 1rem;
 }
@@ -47,15 +56,32 @@ body {
   }
 }
 
-.grw-fixed-controls-container {
+.grw-fab {
   position: fixed;
-  right: 1em;
-  bottom: 3em;
+  right: 1.5rem;
+  bottom: 3rem;
+  z-index: $zindex-fixed;
 
   transition: all 200ms linear;
 
-  .grw-fixed-controls-button-container {
-    box-shadow: 0 3px 4px rgba($black, 0.3);
+  .btn-create-page {
+    width: 60px;
+    height: 60px;
+    font-size: 24px;
+
+    box-shadow: 2px 3px 6px #0000005d;
+  }
+
+  .btn-scroll-to-top {
+    width: 40px;
+    height: 40px;
+
+    opacity: 0.4;
+
+    i {
+      display: inline-block;
+      transform: rotate(90deg);
+    }
   }
 }
 

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

@@ -23,7 +23,8 @@
 
   .revision-toc {
     position: sticky;
-    top: calc(46px + 5px);
+    // growisubnavigation + grw-navbar-boder
+    top: calc(100px + 4px);
     min-width: 100%;
     margin-top: 5px;
 

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

@@ -159,10 +159,6 @@
       color: white;
     }
   }
-
-  .grw-fixed-controls-container {
-    display: none;
-  }
 }
 
 .login-page {

+ 6 - 1
src/client/styles/scss/_mixins.scss

@@ -16,7 +16,7 @@
 }
 
 @mixin expand-editor($editor-header-plus-footer, $navbar-height-adjustment: 0px) {
-  $navbar-height: $grw-navbar-height + $grw-navbar-border-width + $navbar-height-adjustment;
+  $navbar-height: $grw-navbar-border-width + $navbar-height-adjustment;
   $header-plus-footer: $navbar-height + $editor-header-plus-footer + 2px; // add .main padding-top
 
   $editor-margin: $header-plus-footer //
@@ -218,3 +218,8 @@
     fill: $color;
   }
 }
+
+@mixin apply-navigation-transition() {
+  transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
+  transition-duration: 300ms;
+}

+ 12 - 5
src/client/styles/scss/_navbar.scss

@@ -11,11 +11,6 @@
     @include variable-font-size(24px);
   }
 
-  .grw-navbar-toggler {
-    padding: 0.5rem;
-    font-size: 1.5em;
-  }
-
   .grw-navbar-search {
     position: absolute;
     left: 50%;
@@ -60,3 +55,15 @@
     }
   }
 }
+
+.grw-navbar-bottom {
+  height: $grw-navbar-bottom-height;
+
+  // apply transition
+  transition-property: bottom;
+  @include apply-navigation-transition();
+
+  &.grw-navbar-bottom-drawer-opened {
+    bottom: -$grw-navbar-bottom-height;
+  }
+}

+ 2 - 9
src/client/styles/scss/_navbar_kibela.scss

@@ -20,7 +20,8 @@
         }
       }
     }
-    .create-page {
+
+    .btn-create-page {
       background: #5584e1;
       border-radius: 0.35em;
       &:hover {
@@ -31,13 +32,5 @@
         color: white;
       }
     }
-    @media screen and (max-width: 790px) {
-      .search-top {
-        display: none !important;
-      }
-      @media screen and (max-width: 540px) {
-        // TODO responsive after implementation of Sidebar
-      }
-    }
   }
 }

+ 56 - 58
src/client/styles/scss/_on-edit.scss

@@ -11,10 +11,21 @@ body:not(.on-edit) {
 body.on-edit {
   overflow-y: hidden !important;
 
+  .grw-navbar {
+    position: fixed !important;
+    width: 100vw;
+  }
+
+  .page-wrapper {
+    position: relative;
+    top: $grw-navbar-border-width;
+    height: calc(100vh - #{$grw-navbar-border-width});
+  }
+
   // calculate margin
-  $editor-header-plus-footer: 42px // .nav-tabs height
-    + 1px //                          .page-editor-footer border-top
-    + 40px !default; //               .page-editor-footer min-height
+  $editor-header-plus-footer: 42px //               .nav-tabs height
+    + 1px //                                        .page-editor-footer border-top
+    + $grw-editor-navbar-bottom-height !default; // .EditorNavbarBottom min-height
 
   @include expand-editor($editor-header-plus-footer);
 
@@ -26,6 +37,10 @@ body.on-edit {
   }
 
   // show
+  .d-edit-block {
+    display: block !important;
+  }
+
   .d-edit-sm-block {
     @include media-breakpoint-up(sm) {
       display: block !important;
@@ -33,12 +48,15 @@ body.on-edit {
   }
 
   // hide unnecessary elements
-  header,
-  footer,
   .d-edit-none {
     display: none !important;
   }
 
+  // hide when HackMD view
+  &.hackmd .d-hackmd-none {
+    display: none;
+  }
+
   // hide unnecessary elements for growi layout
   .revision-toc-container {
     display: none !important;
@@ -53,24 +71,10 @@ body.on-edit {
     display: none;
   }
 
-  &.hackmd {
-    #page-editor-options-selector {
-      display: none !important;
-    }
-  }
-
   &:not(.hackmd) .nav-tab-hackmd {
     display: none;
   }
 
-  // hide hackmd related alert
-  &.hackmd #page-status-alert {
-    .alert-hackmd-someone-editing,
-    .alert-hackmd-draft-exists {
-      display: none;
-    }
-  }
-
   /*****************
    * Expand Editor
    *****************/
@@ -99,6 +103,8 @@ body.on-edit {
   }
 
   .grw-page-path-nav-for-edit {
+    position: absolute;
+
     .grw-page-path-link {
       font-size: 20px;
       line-height: 1em;
@@ -113,20 +119,23 @@ body.on-edit {
     line-height: 1em;
   }
 
-  .page-editor-footer {
-    width: 100%;
-    min-height: 40px;
-    padding: 3px;
-    margin: 0;
-    border-top: solid 1px transparent;
+  .grw-editor-navbar-bottom {
+    height: $grw-editor-navbar-bottom-height;
 
     .grw-grant-selector {
-      .dropdown-toggle {
-        min-width: 100px;
-
-        // caret
-        &::after {
-          margin-left: 1em;
+      @include media-breakpoint-down(sm) {
+        .btn .label {
+          display: none;
+        }
+      }
+      @include media-breakpoint-up(md) {
+        .dropdown-toggle {
+          min-width: 100px;
+
+          // caret
+          &::after {
+            margin-left: 1em;
+          }
         }
       }
     }
@@ -134,6 +143,17 @@ body.on-edit {
     .btn-submit {
       width: 100px;
     }
+
+    .btn-expand {
+      // rotate icon
+      i {
+        display: inline-block;
+        transition: transform 200ms;
+      }
+      &.expand i {
+        transform: rotate(-180deg);
+      }
+    }
   }
 
   /*********************
@@ -199,30 +219,12 @@ body.on-edit {
       overflow-y: scroll;
     }
 
-    #page-editor-options-selector {
-      .grw-editor-configuration-dropdown {
-        .icon-container {
-          display: inline-block;
-          width: 20px;
-        }
-
-        .dropdown-menu > li > a {
-          display: flex;
-          align-items: center;
-          justify-content: space-between;
-        }
-      }
-    }
-
-    #page-grant-selector {
-      .btn-group {
-        min-width: 150px;
+    .grw-editor-configuration-dropdown {
+      .icon-container {
+        width: 20px;
       }
-    }
-
-    #page-grant-selector {
-      .btn-group {
-        min-width: 150px;
+      .menuitem-label {
+        min-width: 130px;
       }
     }
   }
@@ -230,10 +232,6 @@ body.on-edit {
   // .builtin-editor .tab-pane#edit
 
   &.hackmd {
-    #page-editor-options-selector {
-      display: none;
-    }
-
     .hackmd-preinit,
     #iframe-hackmd-container > iframe {
       border: none;

+ 5 - 0
src/client/styles/scss/_override-bootstrap.scss

@@ -88,6 +88,11 @@
     }
   }
 
+  // Badges
+  .badge {
+    @extend .badge-pill;
+  }
+
   //Modals
   .modal-open {
     position: fixed;

+ 8 - 0
src/client/styles/scss/_override-rbt.scss

@@ -21,3 +21,11 @@
 .rbt-aux {
   display: none;
 }
+
+// seamless border for .input-group-prepend
+.input-group-prepend + div {
+  .rbt .rbt-input-main {
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+  }
+}

+ 38 - 0
src/client/styles/scss/_page.scss

@@ -179,3 +179,41 @@
     border: 0;
   }
 }
+
+.card.grw-page-status-alert {
+  $margin-bottom: $grw-navbar-bottom-height + 10px;
+
+  box-shadow: 0px 2px 4px #0000004d;
+  opacity: 0.9;
+
+  @include media-breakpoint-down(sm) {
+    margin: 0 10px $margin-bottom;
+
+    .grw-card-label-container {
+      text-align: center;
+    }
+    .grw-card-btn-container {
+      text-align: center;
+
+      .btn {
+        @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-line-height-lg, $btn-border-radius-lg);
+      }
+    }
+  }
+
+  @include media-breakpoint-up(md) {
+    width: 700px;
+    margin: 0 auto $margin-bottom;
+
+    .card-body {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .grw-card-label-container,
+    .grw-card-btn-container {
+      margin: 0;
+    }
+  }
+}

+ 29 - 31
src/client/styles/scss/_search.scss

@@ -59,7 +59,7 @@
 }
 
 // input styles
-.search-top {
+.grw-global-search {
   .search-clear {
     top: 3px;
     right: 26px;
@@ -115,44 +115,42 @@
 }
 
 // layout
-.search-top {
-  .grw-search-top-absolute {
-    // centering on navbar
-    top: $grw-navbar-height / 2;
-    left: 50vw;
-    z-index: $zindex-fixed + 1;
-    transform: translate(-50%, -50%);
+.grw-global-search-top {
+  // centering on navbar
+  top: $grw-navbar-height / 2;
+  left: 50vw;
+  z-index: $zindex-fixed + 1;
+  transform: translate(-50%, -50%);
 
-    .rbt-input.form-control {
-      width: 200px;
-      transition: 0.3s ease-out;
+  .rbt-input.form-control {
+    width: 200px;
+    transition: 0.3s ease-out;
+
+    // focus
+    &.focus {
+      width: 300px;
+    }
 
+    @include media-breakpoint-up(md) {
+      width: 300px;
+    }
+    @include media-breakpoint-up(lg) {
       // focus
       &.focus {
-        width: 300px;
-      }
-
-      @include media-breakpoint-up(md) {
-        width: 300px;
-      }
-      @include media-breakpoint-up(lg) {
-        // focus
-        &.focus {
-          width: 400px;
-        }
-      }
-      @include media-breakpoint-up(xl) {
-        width: 350px;
-        // focus
-        &.focus {
-          width: 450px;
-        }
+        width: 400px;
       }
     }
-    .search-typeahead {
-      border-radius: 0 25px 25px 0;
+    @include media-breakpoint-up(xl) {
+      width: 350px;
+      // focus
+      &.focus {
+        width: 450px;
+      }
     }
   }
+  .search-typeahead {
+    border-radius: 0 25px 25px 0;
+  }
 }
 
 .search-result {

+ 61 - 31
src/client/styles/scss/_sidebar.scss

@@ -21,7 +21,6 @@
   // sticky
   position: sticky;
   top: $grw-navbar-border-width;
-  z-index: $zindex-sticky;
 
   .ak-navigation-resize-button {
     position: fixed;
@@ -58,8 +57,6 @@
   // override @atlaskit/navigation-next styles
   $navbar-total-height: $grw-navbar-height + $grw-navbar-border-width;
   div[data-layout-container='true'] {
-    height: 100vh;
-
     // css-teprsg
     > div:nth-of-type(2) {
       padding-left: unset !important;
@@ -67,15 +64,6 @@
     }
   }
   div[data-testid='Navigation'] {
-    position: unset;
-
-    top: $navbar-total-height;
-
-    // Adjust to be on top of the growi subnavigation
-    // z-index: $zindex-sticky + 5;
-
-    transition: left 300ms cubic-bezier(0.25, 1, 0.5, 1);
-
     // css-xxx-ContainerNavigationMask
     > div:nth-of-type(1) {
     }
@@ -140,47 +128,89 @@
       }
     }
   }
+
+  .grw-drawer-toggler {
+    display: none; // invisible in default
+  }
 }
 
-// Drawer Mode
-@mixin drawer() {
-  position: fixed;
-  z-index: $zindex-fixed - 2;
+// Dock Mode
+@mixin dock() {
+  z-index: $zindex-sticky;
 
   // override @atlaskit/navigation-next styles
+  $navbar-total-height: $grw-navbar-height + $grw-navbar-border-width;
   div[data-layout-container='true'] {
-    // css-teprsg
-    > div:nth-of-type(2) {
-      display: none;
-    }
+    max-height: calc(100vh - #{$grw-navbar-border-width});
   }
   div[data-testid='Navigation'] {
-    // css-xxx-Outer
-    > div:nth-of-type(2) {
-      display: none;
-    }
+    position: unset;
+
+    top: $navbar-total-height;
+  }
+}
+
+// Drawer Mode
+@mixin drawer() {
+  z-index: $zindex-fixed + 2;
+
+  // override @atlaskit/navigation-next styles
+  div[data-testid='Navigation'] {
+    max-width: 80vw;
+
+    // apply transition
+    transition-property: transform;
+    @include apply-navigation-transition();
   }
 
   &:not(.open) {
     div[data-testid='Navigation'] {
-      left: -#{$grw-sidebar-nav-width + $grw-sidebar-content-min-width};
+      transform: translateX(-100%);
     }
   }
   &.open {
     div[data-testid='Navigation'] {
-      left: 0;
+      transform: translateX(0);
+    }
+
+    .grw-drawer-toggler {
+      display: block;
     }
   }
-}
 
-.grw-sidebar {
-  &.grw-sidebar-drawer {
-    @include drawer();
+  .grw-drawer-toggler {
+    position: fixed;
+    right: -15px;
+
+    @include media-breakpoint-down(sm) {
+      bottom: 15px;
+      width: 42px;
+      height: 42px;
+      font-size: 18px;
+    }
+    @include media-breakpoint-up(md) {
+      top: 72px;
+      width: 50px;
+      height: 50px;
+      font-size: 24px;
+    }
+
+    transform: translateX(100%);
   }
+}
 
+.grw-sidebar {
   @include media-breakpoint-down(sm) {
     @include drawer();
   }
+  @include media-breakpoint-up(md) {
+    &.grw-sidebar-drawer {
+      @include drawer();
+    }
+    &:not(.grw-sidebar-drawer) {
+      @include dock();
+    }
+  }
 }
 
 // supress transition
@@ -200,5 +230,5 @@
 }
 
 .grw-sidebar-backdrop.modal-backdrop {
-  z-index: $zindex-fixed - 4;
+  z-index: $zindex-fixed + 1;
 }

+ 76 - 24
src/client/styles/scss/_subnav.scss

@@ -1,26 +1,10 @@
-$easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
-
-%transitionForCompactMode {
-  // set transition-duration (normal -> compact)
-  transition: all 300ms $easeInOutCubic;
-}
-
-/*
- * Styles
- */
-
-.grw-header {
-  .title {
-    padding: 0.5rem 15px;
-
-    line-height: 1em;
+.grw-subnav {
+  padding: 10px 15px;
 
-    @include variable-font-size(28px);
-    line-height: 1.1em;
+  @include media-breakpoint-up(md) {
+    min-height: 115px;
   }
-}
 
-.grw-subnavbar {
   &:hover {
     .btn-copy,
     .btn-edit,
@@ -30,9 +14,15 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     }
   }
 
+  .grw-drawer-toggler {
+    width: 50px;
+    height: 50px;
+    font-size: 24px;
+  }
+
   h1 {
-    @include variable-font-size(28px);
-    line-height: 1.1em;
+    @include variable-font-size(32px);
+    line-height: 1.4em;
   }
 
   .grw-page-path-nav {
@@ -42,15 +32,27 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     }
   }
 
+  .btn-like,
+  .btn-bookmark {
+    width: 40px;
+    height: 40px;
+    font-size: 20px;
+  }
+
   ul.authors {
-    padding-left: 1.5em;
-    margin: 0;
+    padding: 0.7em 0 0.7em 1.5em;
+    margin-bottom: 0;
+    margin-left: 1em;
 
     li {
       font-size: 12px;
       list-style: none;
     }
 
+    .text-date {
+      font-size: 11px;
+    }
+
     .picture {
       width: 22px;
       height: 22px;
@@ -62,4 +64,54 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
       }
     }
   }
+
+  /*
+   * Compact Mode
+   */
+  &.grw-subnav-compact {
+    min-height: 70px;
+
+    @include media-breakpoint-up(md) {
+      min-height: 90px;
+    }
+
+    .btn-like,
+    .btn-bookmark {
+      @extend .btn-sm;
+
+      width: 30px;
+      height: 30px;
+      font-size: 15px !important;
+    }
+  }
+}
+
+/*
+ * Fixed ver
+ */
+$easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
+
+.grw-subnav-fixed-container {
+  top: $grw-navbar-border-width;
+  z-index: $zindex-sticky - 5;
+
+  .grw-subnav {
+    box-shadow: 0px 6px 6px -3px rgba(black, 0.15);
+  }
+}
+
+/*
+ * Switching show/hide
+ */
+.grw-subnav-switcher {
+  .grw-subnav-fixed-container {
+    transition: transform 150ms $easeInOutCubic;
+  }
+
+  &.grw-subnav-switcher-hidden {
+    .grw-subnav-fixed-container {
+      transition: unset;
+      transform: translateY(-100%);
+    }
+  }
 }

+ 4 - 17
src/client/styles/scss/_tag.scss

@@ -4,24 +4,11 @@
   }
 }
 
-.tag-labels {
-  .manage-tags {
-    font-size: 10px;
-    cursor: pointer;
-  }
-
-  .tag-icon:not(:first-child) {
-    margin-left: 5px;
-  }
-
-  .btn.btn-edit-tags,
-  .tag-icon {
-    font-size: 10px;
-  }
-
-  .tag-name {
+.grw-tag-labels {
+  .grw-tag-label {
     margin-left: 1px;
-    font-size: 10px;
+    font-size: 12px;
+    border-radius: $border-radius-xl;
   }
 }
 

+ 3 - 31
src/client/styles/scss/_user.scss

@@ -8,16 +8,12 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
 /*
  * Styles
  */
-.grw-subnavbar-user-page {
-  #revision-path {
-    margin-bottom: 0;
-  }
-
+.grw-users-info {
   .users-meta {
     margin-left: 30px;
   }
 
-  h1 {
+  .user-page-name {
     margin: 0;
     font-size: 2.5em;
     color: #666;
@@ -28,36 +24,12 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     height: 72px;
   }
 
-  ul.user-page-meta {
+  div.user-page-meta {
     padding-left: 0;
     color: #999;
 
-    li {
-      list-style: none;
-    }
-
     .user-page-username {
       font-weight: bold;
-
-      .user-page-email {
-      }
-
-      .user-page-introduction {
-      }
-    }
-
-    .user-page-email {
-    }
-
-    .user-page-introduction {
-    }
-  }
-
-  .btn.btn-bookmark {
-    &.btn-lg {
-      width: 50px;
-      height: 50px;
-      font-size: 1.5em;
     }
   }
 }

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

@@ -9,6 +9,9 @@ $font-family-monospace-not-strictly: Monaco, Menlo, Consolas, 'Courier New', Mei
 $grw-navbar-height: 52px;
 $grw-navbar-border-width: 3.3333px;
 
+$grw-navbar-bottom-height: 48px;
+$grw-editor-navbar-bottom-height: 48px;
+
 $grw-sidebar-nav-width: 64px; // !!DO NOT CHANGE!! 'margin-left' for '.css-teprsg' is hardcoded
 $grw-sidebar-content-min-width: 240px;
 
@@ -18,4 +21,5 @@ $grw-logomark-width: 36px;
 // fix tab width to 95 pixels
 // see also '_on-edit.scss'
 $grw-nav-main-left-tab-width: 95px;
+$grw-nav-main-left-tab-width-mobile: 50px;
 $grw-nav-main-tab-height: 42px;

+ 4 - 4
src/client/styles/scss/atoms/_buttons.scss

@@ -1,5 +1,7 @@
-.btn.btn-like,
-.btn.btn-bookmark {
+.btn.btn-outline-info.btn-like,
+.btn.btn-outline-warning.btn-bookmark {
+  color: $secondary;
+
   &.active,
   &:hover {
     // header buttons are always white for active
@@ -9,8 +11,6 @@
   &:not(:hover):not(.active) {
     background-color: transparent;
   }
-  width: 35px;
-  height: 35px;
 }
 
 .btn-copy,

+ 3 - 2
src/client/styles/scss/atoms/_nav.scss

@@ -1,8 +1,9 @@
 .nav-tabs .grw-main-nav-item-left {
   width: $grw-nav-main-left-tab-width;
   text-align: center;
-  @include media-breakpoint-down(xs) {
-    width: 45px;
+
+  @include media-breakpoint-down(sm) {
+    width: $grw-nav-main-left-tab-width-mobile;
   }
 
   .nav-link {

+ 34 - 9
src/client/styles/scss/theme/_apply-colors-dark.scss

@@ -5,21 +5,25 @@ $color-list-hover: $color-global !default;
 $bgcolor-list-hover: lighten($bgcolor-global, 3%) !default;
 $color-list-active: $color-reversal !default;
 $bgcolor-list-active: $primary !default;
-$bgcolor-subnabvar: lighten($bgcolor-global, 3%) !default;
+$bgcolor-subnav: lighten($bgcolor-global, 3%) !default;
 $color-table: white !default;
 $bgcolor-table: #343a40 !default;
 $border-color-table: lighten($bgcolor-table, 7.5%) !default;
 $color-table-hover: rgba(white, 0.075) !default;
 $bgcolor-table-hover: lighten($bgcolor-table, 7.5%) !default;
 $bgcolor-sidebar-list-group: $bgcolor-list !default;
+$color-tags: #949494 !default;
+$bgcolor-tags: $dark !default;
 
 // override bootstrap variables
+$border-color: #444;
 $table-dark-color: $color-table;
 $table-dark-bg: $bgcolor-table;
 $table-dark-border-color: $border-color-table;
 $table-dark-hover-color: $color-table-hover;
 $table-dark-hover-bg: $bgcolor-table-hover;
 
+@import 'reboot-bootstrap-border-colors';
 @import 'reboot-bootstrap-tables';
 
 // List Group
@@ -46,11 +50,10 @@ textarea.form-control {
   background-color: lighten($bgcolor-global, 5%);
 }
 
-.input-group .input-group-addon {
-  color: theme-color('dark');
-  background-color: rgba($bgcolor-navbar, 0.4);
-  // FIXME: accent color
-  // border: 1px solid darken($border, 30%);
+.input-group > .input-group-prepend > .input-group-text {
+  color: color-yiq(theme-color('dark'));
+  background-color: theme-color('dark');
+  border: 1px solid theme-color('secondary');
   border-right: none;
 }
 
@@ -172,6 +175,14 @@ ul.pagination {
   }
 }
 
+/*
+ * GROWI subnavigation
+ */
+.grw-drawer-toggler {
+  @extend .btn-dark;
+  color: #999;
+}
+
 /*
  * GROWI page list
  */
@@ -188,8 +199,12 @@ ul.pagination {
 /*
  * GROWI subnavigation
  */
-.grw-subnavbar {
-  background-color: $bgcolor-subnabvar;
+.grw-subnav {
+  background-color: $bgcolor-subnav;
+}
+
+.grw-subnav-fixed-container .grw-subnav {
+  background-color: rgba($bgcolor-subnav, 0.85);
 }
 
 // Search drop down
@@ -211,7 +226,7 @@ ul.pagination {
 /*
  * GROWI on-edit
  */
-.page-editor-footer {
+.grw-editor-navbar-bottom {
   #slack-mark-black {
     display: none;
   }
@@ -241,3 +256,13 @@ ul.pagination {
     display: none;
   }
 }
+
+/*
+ * GROWI tags
+ */
+.grw-tag-labels {
+  .grw-tag-label {
+    color: $color-tags;
+    background-color: $bgcolor-tags;
+  }
+}

+ 3 - 4
src/client/styles/scss/theme/_apply-colors-kibela.scss

@@ -16,13 +16,12 @@ body.kibela {
       background-color: $primary !important;
     }
 
-    .grw-subnavbar {
+    .grw-subnav {
       background-color: rgba(lighten($bgcolor-global, 50%), 1);
     }
 
-    /* kibela block */
-    .kibela-border-top {
-      border-top: solid 0.4em $thickborder;
+    .grw-subnav-fixed-container .grw-subnav {
+      background-color: rgba(lighten($bgcolor-global, 50%), 0.85);
     }
 
     /* page wrapper */

+ 27 - 4
src/client/styles/scss/theme/_apply-colors-light.scss

@@ -5,21 +5,25 @@ $color-list-hover: $color-global !default;
 $bgcolor-list-hover: darken($bgcolor-global, 3%) !default;
 $color-list-active: $color-reversal !default;
 $bgcolor-list-active: $primary !default;
-$bgcolor-subnabvar: darken($bgcolor-global, 3%) !default;
+$bgcolor-subnav: darken($bgcolor-global, 3%) !default;
 $color-table: $color-global !default;
 $bgcolor-table: null !default;
 $border-color-table: #dee2e6 !default;
 $color-table-hover: $color-table !default;
 $bgcolor-table-hover: rgba(black, 0.075) !default;
 $bgcolor-sidebar-list-group: $bgcolor-list !default;
+$color-tags: #949494 !default;
+$bgcolor-tags: #ebebeb !default;
 
 // override bootstrap variables
+$border-color: #dee2e6;
 $table-color: $color-table;
 $table-bg: $bgcolor-table;
 $table-border-color: $border-color-table;
 $table-hover-color: $color-table-hover;
 $table-hover-bg: $bgcolor-table-hover;
 
+@import 'reboot-bootstrap-border-colors';
 @import 'reboot-bootstrap-tables';
 
 // List Group
@@ -120,8 +124,17 @@ $table-hover-bg: $bgcolor-table-hover;
 /*
  * GROWI subnavigation
  */
-.grw-subnavbar {
-  background-color: $bgcolor-subnabvar;
+.grw-subnav {
+  background-color: $bgcolor-subnav;
+}
+
+.grw-subnav-fixed-container .grw-subnav {
+  background-color: rgba($bgcolor-subnav, 0.85);
+}
+
+.grw-drawer-toggler {
+  @extend .btn-light;
+  color: #999;
 }
 
 /*
@@ -148,7 +161,7 @@ $table-hover-bg: $bgcolor-table-hover;
 /*
  * GROWI on-edit
  */
-.page-editor-footer {
+.grw-editor-navbar-bottom {
   #slack-mark-white {
     display: none;
   }
@@ -178,3 +191,13 @@ $table-hover-bg: $bgcolor-table-hover;
     display: none;
   }
 }
+
+/*
+ * GROWI tags
+ */
+.grw-tag-labels {
+  .grw-tag-label {
+    color: $color-tags;
+    background-color: $bgcolor-tags;
+  }
+}

+ 5 - 1
src/client/styles/scss/theme/_apply-colors.scss

@@ -152,7 +152,7 @@ pre:not(.hljs):not(.CodeMirror-line) {
   }
 }
 
-.search-top {
+.grw-global-search {
   .btn-secondary.dropdown-toggle {
     @include button-variant($bgcolor-search-top-dropdown, $bgcolor-search-top-dropdown);
   }
@@ -328,6 +328,10 @@ body.on-edit {
       border-top-color: $border-color-theme;
     }
   }
+
+  .grw-editor-navbar-bottom {
+    background-color: darken($bgcolor-global, 2%);
+  }
 }
 
 /*

+ 23 - 0
src/client/styles/scss/theme/_reboot-bootstrap-border-colors.scss

@@ -0,0 +1,23 @@
+//
+// Border
+//
+
+.border {
+  border: $border-width solid $border-color !important;
+}
+
+.border-top {
+  border-top: $border-width solid $border-color !important;
+}
+
+.border-right {
+  border-right: $border-width solid $border-color !important;
+}
+
+.border-bottom {
+  border-bottom: $border-width solid $border-color !important;
+}
+
+.border-left {
+  border-left: $border-width solid $border-color !important;
+}

+ 3 - 0
src/client/styles/scss/theme/_reboot-bootstrap-theme-colors.scss

@@ -66,4 +66,7 @@ $theme-colors: map-merge($theme-colors, $colors);
   .badge-#{$color} {
     @include badge-variant($value);
   }
+  a.badge-#{$color} {
+    @include badge-variant($value);
+  }
 }

+ 11 - 3
src/client/styles/scss/theme/default.scss

@@ -42,7 +42,7 @@ html[light] {
   // $bgcolor-list-active: $primary; // optional
 
   // Table colors
-  // $bgcolor-subnabvar: #; // optional
+  // $bgcolor-subnav: #; // optional
   // $color-table: #; // optional
   // $bgcolor-table: #; // optional
   // $border-color-table: #; // optional
@@ -74,13 +74,17 @@ html[light] {
   $bgcolor-sidebar-list-group: #fafbff; // optional
 
   // Subnavigation
-  // $bgcolor-subnabvar: #fafafa; // optional
+  // $bgcolor-subnav: #fafafa; // optional
 
   // Tabs
   // $color-nav-tabs-link-active: #; //optional
   // $bordercolor-nav-tabs-hover: # # $bordercolor-nav-tabs; // optional
   // $bordercolor-nav-tabs-active: # # $bgcolor-global; // optional
 
+  // Tags
+  // $color-tags: #; //optional
+  // $bgcolor-tags: #; //optional
+
   // Icon colors
   $color-editor-icons: $color-global;
 
@@ -161,7 +165,7 @@ html[dark] {
   $bgcolor-sidebar-list-group: #1c2a3e; // optional
 
   // Subnavigation
-  $bgcolor-subnabvar: lighten($bgcolor-global, 4%); // optional
+  $bgcolor-subnav: lighten($bgcolor-global, 4%); // optional
 
   // Tabs
   $bordercolor-nav-tabs: #444; // optional
@@ -169,6 +173,10 @@ html[dark] {
   $bordercolor-nav-tabs-hover: #666 #666 $bordercolor-nav-tabs; // optional
   // $bordercolor-nav-tabs-active: # # $bgcolor-global; // optional
 
+  // Tags
+  // $color-tags: #; //optional
+  // $bgcolor-tags: #; //optional
+
   // Icon colors
   $color-editor-icons: $color-global;
 

+ 2 - 2
src/client/styles/scss/theme/nature.scss

@@ -44,7 +44,7 @@ html[dark] {
   $bgcolor-global: #fdfdfd;
   $bgcolor-inline-code: #f0f0f0; //optional
   $bgcolor-card: #f1ffe4;
-  $bgcolor-subnabvar: #fafafa;
+  $bgcolor-subnav: #fafafa;
 
   // Font colors
   $color-global: #460039;
@@ -97,7 +97,7 @@ html[dark] {
   @import 'apply-colors-light';
 
   // Search Top
-  .search-top {
+  .grw-global-search {
     .btn-secondary.dropdown-toggle {
       color: $color-search;
     }

+ 1 - 0
src/server/models/config.js

@@ -221,6 +221,7 @@ module.exports = function(crowi) {
       recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       isEnabledStaleNotification: crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAclEnabled: crowi.aclService.isAclEnabled(),
+      isSearchServiceConfigured: crowi.searchService.isConfigured,
       isSearchServiceReachable: crowi.searchService.isReachable,
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };

+ 33 - 22
src/server/models/page.js

@@ -693,7 +693,7 @@ module.exports = function(crowi) {
   /**
    * find pages that is match with `path` and its descendants
    */
-  pageSchema.statics.findListWithDescendants = async function(path, user, option) {
+  pageSchema.statics.findListWithDescendants = async function(path, user, option = {}) {
     const builder = new PageQueryBuilder(this.find());
     builder.addConditionToListWithDescendants(path, option);
 
@@ -1094,21 +1094,18 @@ module.exports = function(crowi) {
       throw new Error('This method does NOT supports deleting trashed pages.');
     }
 
-    const findOpts = { includeRedirect: true };
-    const result = await this.findListWithDescendants(targetPage.path, user, findOpts);
+    // find descendants (this array does not include GRANT_RESTRICTED)
+    const result = await this.findListWithDescendants(targetPage.path, user);
     const pages = result.pages;
+    // add targetPage if 'grant' is GRANT_RESTRICTED
+    //  because findListWithDescendants excludes GRANT_RESTRICTED pages
+    if (targetPage.grant === GRANT_RESTRICTED) {
+      pages.push(targetPage);
+    }
 
-    let updatedPage = null;
     await Promise.all(pages.map((page) => {
-      const isParent = (page.path === targetPage.path);
-      const p = this.deletePage(page, user, options);
-      if (isParent) {
-        updatedPage = p;
-      }
-      return p;
+      return this.deletePage(page, user, options);
     }));
-
-    return updatedPage;
   };
 
   pageSchema.statics.revertDeletedPage = async function(page, user, options = {}) {
@@ -1135,7 +1132,7 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.revertDeletedPageRecursively = async function(targetPage, user, options = {}) {
-    const findOpts = { includeRedirect: true, includeTrashed: true };
+    const findOpts = { includeTrashed: true };
     const result = await this.findListWithDescendants(targetPage.path, user, findOpts);
     const pages = result.pages;
 
@@ -1185,17 +1182,23 @@ module.exports = function(crowi) {
   /**
    * Delete Bookmarks, Attachments, Revisions, Pages and emit delete
    */
-  pageSchema.statics.completelyDeletePageRecursively = async function(pagePath, user, options = {}) {
+  pageSchema.statics.completelyDeletePageRecursively = async function(targetPage, user, options = {}) {
+    const pagePath = targetPage.path;
 
-    const findOpts = { includeRedirect: true, includeTrashed: true };
+    const findOpts = { includeTrashed: true };
+
+    // find descendants (this array does not include GRANT_RESTRICTED)
     const result = await this.findListWithDescendants(pagePath, user, findOpts);
     const pages = result.pages;
+    // add targetPage if 'grant' is GRANT_RESTRICTED
+    //  because findListWithDescendants excludes GRANT_RESTRICTED pages
+    if (targetPage.grant === GRANT_RESTRICTED) {
+      pages.push(targetPage);
+    }
 
     await Promise.all(pages.map((page) => {
       return this.completelyDeletePage(page, user, options);
     }));
-
-    return pagePath;
   };
 
   pageSchema.statics.removeByPath = function(path) {
@@ -1263,22 +1266,30 @@ module.exports = function(crowi) {
     return updatedPageData;
   };
 
-  pageSchema.statics.renameRecursively = async function(pageData, newPagePathPrefix, user, options) {
+  pageSchema.statics.renameRecursively = async function(targetPage, newPagePathPrefix, user, options) {
     validateCrowi();
 
-    const path = pageData.path;
+    const path = targetPage.path;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(path)}`, 'i');
 
     // sanitize path
     newPagePathPrefix = crowi.xss.process(newPagePathPrefix); // eslint-disable-line no-param-reassign
 
+    // find descendants (this array does not include GRANT_RESTRICTED)
     const result = await this.findListWithDescendants(path, user, options);
-    await Promise.all(result.pages.map((page) => {
+    const pages = result.pages;
+    // add targetPage if 'grant' is GRANT_RESTRICTED
+    //  because findListWithDescendants excludes GRANT_RESTRICTED pages
+    if (targetPage.grant === GRANT_RESTRICTED) {
+      pages.push(targetPage);
+    }
+
+    await Promise.all(pages.map((page) => {
       const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
       return this.rename(page, newPagePath, user, options);
     }));
-    pageData.path = newPagePathPrefix;
-    return pageData;
+    targetPage.path = newPagePathPrefix;
+    return targetPage;
   };
 
   pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {

+ 0 - 3
src/server/models/user.js

@@ -21,8 +21,6 @@ module.exports = function(crowi) {
   const STATUS_INVITED = 5;
   const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
   + 'status lang createdAt lastLoginAt admin imageUrlCached';
-  /* eslint-disable no-unused-vars */
-  const IMAGE_POPULATION = { path: 'imageAttachment', select: 'filePathProxied' };
 
   const PAGE_ITEMS = 50;
 
@@ -750,7 +748,6 @@ module.exports = function(crowi) {
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED = STATUS_INVITED;
   userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
-  userSchema.statics.IMAGE_POPULATION = IMAGE_POPULATION;
   userSchema.statics.PAGE_ITEMS = PAGE_ITEMS;
 
   return mongoose.model('User', userSchema);

+ 56 - 0
src/server/routes/apiv3/security-setting.js

@@ -65,6 +65,14 @@ const validator = {
   oidcAuth: [
     body('oidcProviderName').if(value => value != null).isString(),
     body('oidcIssuerHost').if(value => value != null).isString(),
+    body('oidcAuthorizationEndpoint').if(value => value != null).isString(),
+    body('oidcTokenEndpoint').if(value => value != null).isString(),
+    body('oidcRevocationEndpoint').if(value => value != null).isString(),
+    body('oidcIntrospectionEndpoint').if(value => value != null).isString(),
+    body('oidcUserInfoEndpoint').if(value => value != null).isString(),
+    body('oidcEndSessionEndpoint').if(value => value != null).isString(),
+    body('oidcRegistrationEndpoint').if(value => value != null).isString(),
+    body('oidcJWKSUri').if(value => value != null).isString(),
     body('oidcClientId').if(value => value != null).isString(),
     body('oidcClientSecret').if(value => value != null).isString(),
     body('oidcAttrMapId').if(value => value != null).isString(),
@@ -219,6 +227,30 @@ const validator = {
  *          oidcIssuerHost:
  *            type: string
  *            description: issuer host for oidc
+ *          oidcAuthorizationEndpoint:
+ *            type: string
+ *            description: authorization endpoint for oidc
+ *          oidcTokenEndpoint:
+ *            type: string
+ *            description: token endpoint for oidc
+ *          oidcRevocationEndpoint:
+ *            type: string
+ *            description: revocation endpoint for oidc
+ *          oidcIntrospectionEndpoint:
+ *            type: string
+ *            description: introspection endpoint for oidc
+ *          oidcUserInfoEndpoint:
+ *            type: string
+ *            description: userinfo endpoint for oidc
+ *          oidcEndSessionEndpoint:
+ *            type: string
+ *            description: end session endpoint for oidc
+ *          oidcRegistrationEndpoint:
+ *            type: string
+ *            description: registration endpoint for oidc
+ *          oidcJWKSUri:
+ *            type: string
+ *            description: JSON Web Key Set URI for oidc
  *          oidcClientId:
  *            type: string
  *            description: client id for oidc
@@ -376,6 +408,14 @@ module.exports = (crowi) => {
       oidcAuth: {
         oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
         oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
+        oidcAuthorizationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
+        oidcTokenEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
+        oidcRevocationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
+        oidcIntrospectionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
+        oidcUserInfoEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
+        oidcEndSessionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
+        oidcRegistrationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
+        oidcJWKSUri: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
         oidcClientId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
         oidcClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
         oidcAttrMapId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
@@ -767,6 +807,14 @@ module.exports = (crowi) => {
     const requestParams = {
       'security:passport-oidc:providerName': req.body.oidcProviderName,
       'security:passport-oidc:issuerHost': req.body.oidcIssuerHost,
+      'security:passport-oidc:authorizationEndpoint': req.body.oidcAuthorizationEndpoint,
+      'security:passport-oidc:tokenEndpoint': req.body.oidcTokenEndpoint,
+      'security:passport-oidc:revocationEndpoint': req.body.oidcRevocationEndpoint,
+      'security:passport-oidc:introspectionEndpoint': req.body.oidcIntrospectionEndpoint,
+      'security:passport-oidc:userInfoEndpoint': req.body.oidcUserInfoEndpoint,
+      'security:passport-oidc:endSessionEndpoint': req.body.oidcEndSessionEndpoint,
+      'security:passport-oidc:registrationEndpoint': req.body.oidcRegistrationEndpoint,
+      'security:passport-oidc:jwksUri': req.body.oidcJWKSUri,
       'security:passport-oidc:clientId': req.body.oidcClientId,
       'security:passport-oidc:clientSecret': req.body.oidcClientSecret,
       'security:passport-oidc:attrMapId': req.body.oidcAttrMapId,
@@ -783,6 +831,14 @@ module.exports = (crowi) => {
       const securitySettingParams = {
         oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
         oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
+        oidcAuthorizationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
+        oidcTokenEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
+        oidcRevocationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
+        oidcIntrospectionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
+        oidcUserInfoEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
+        oidcEndSessionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
+        oidcRegistrationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
+        oidcJWKSUri: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
         oidcClientId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
         oidcClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
         oidcAttrMapId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),

+ 47 - 0
src/server/routes/apiv3/users.js

@@ -597,5 +597,52 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/reset-password:
+   *      put:
+   *        tags: [Users]
+   *        operationId: resetPassword
+   *        summary: /users/reset-password
+   *        description: update imageUrlCache
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  id:
+   *                    type: string
+   *                    description: user id for reset password
+   *        responses:
+   *          200:
+   *            description: success resrt password
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    newPassword:
+   *                      type: string
+   *                    user:
+   *                      type: object
+   *                      description: Target user
+   */
+  router.put('/reset-password', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const { id } = req.body;
+
+    try {
+      const [newPassword, user] = await Promise.all([
+        await User.resetPasswordByRandomString(id),
+        await User.findById(id)]);
+
+      return res.apiv3({ newPassword, user });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
   return router;
 };

+ 4 - 0
src/server/routes/attachment.js

@@ -193,6 +193,10 @@ module.exports = function(crowi, app) {
       return res.sendStatus(304);
     }
 
+    if (fileUploader.canRespond()) {
+      return fileUploader.respond(res, attachment);
+    }
+
     let fileStream;
     try {
       fileStream = await fileUploader.findDeliveryFile(attachment);

+ 6 - 6
src/server/routes/page.js

@@ -1231,7 +1231,7 @@ module.exports = function(crowi, app) {
 
     const options = { socketClientId };
 
-    let page = await Page.findByIdAndViewer(pageId, req.user);
+    const page = await Page.findByIdAndViewer(pageId, req.user);
 
     if (page == null) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
@@ -1245,10 +1245,10 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
         }
         if (isRecursively) {
-          await Page.completelyDeletePageRecursively(page.path, req.user, options);
+          await Page.completelyDeletePageRecursively(page, req.user, options);
         }
         else {
-          page = await Page.completelyDeletePage(page, req.user, options);
+          await Page.completelyDeletePage(page, req.user, options);
         }
       }
       else {
@@ -1257,16 +1257,16 @@ module.exports = function(crowi, app) {
         }
 
         if (isRecursively) {
-          page = await Page.deletePageRecursively(page, req.user, options);
+          await Page.deletePageRecursively(page, req.user, options);
         }
         else {
-          page = await Page.deletePage(page, req.user, options);
+          await Page.deletePage(page, req.user, options);
         }
       }
     }
     catch (err) {
       logger.error('Error occured while get setting', err);
-      return res.json(ApiResponse.error('Failed to delete page.', 'unknown'));
+      return res.json(ApiResponse.error('Failed to delete page.', err.message));
     }
 
     debug('Page deleted', page.path);

+ 12 - 0
src/server/service/config-loader.js

@@ -137,6 +137,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.BOOLEAN,
     default: false,
   },
+  FILE_UPLOAD_LOCAL_USE_INTERNAL_REDIRECT: {
+    ns:      'crowi',
+    key:     'fileUpload:local:useInternalRedirect',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
+  FILE_UPLOAD_LOCAL_INTERNAL_REDIRECT_PATH: {
+    ns:      'crowi',
+    key:     'fileUpload:local:internalRedirectPath',
+    type:    TYPES.STRING,
+    default: '/growi-internal/',
+  },
   ELASTICSEARCH_URI: {
     ns:      'crowi',
     key:     'app:elasticsearchUri',

+ 25 - 0
src/server/service/file-uploader/local.js

@@ -4,6 +4,7 @@ const fs = require('fs');
 const path = require('path');
 const mkdir = require('mkdirp');
 const streamToPromise = require('stream-to-promise');
+const urljoin = require('url-join');
 
 module.exports = function(crowi) {
   const Uploader = require('./uploader');
@@ -92,5 +93,29 @@ module.exports = function(crowi) {
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
+  /**
+   * Checks if Uploader can respond to the HTTP request.
+   */
+  lib.canRespond = () => {
+    // Check whether to use internal redirect of nginx or Apache.
+    return process.env.FILE_UPLOAD === 'local' && lib.configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
+  };
+
+  /**
+   * Respond to the HTTP request.
+   * @param {Response} res
+   * @param {Response} attachment
+   */
+  lib.respond = (res, attachment) => {
+    // Responce using internal redirect of nginx or Apache.
+    const storagePath = getFilePathOnStorage(attachment);
+    const relativePath = path.relative(crowi.publicDir, storagePath);
+    const internalPathRoot = lib.configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
+    const internalPath = urljoin(internalPathRoot, relativePath);
+    res.set('X-Accel-Redirect', internalPath);
+    res.set('X-Sendfile', storagePath);
+    return res.end();
+  };
+
   return lib;
 };

+ 16 - 0
src/server/service/file-uploader/uploader.js

@@ -54,6 +54,22 @@ class Uploader {
 
   }
 
+  /**
+   * Checks if Uploader can respond to the HTTP request.
+   */
+  canRespond() {
+    return false;
+  }
+
+  /**
+   * Respond to the HTTP request.
+   * @param {Response} res
+   * @param {Response} attachment
+   */
+  respond(res, attachment) {
+    throw new Error('Implement this');
+  }
+
 }
 
 module.exports = Uploader;

+ 34 - 0
src/server/service/passport.js

@@ -570,6 +570,40 @@ class PassportService {
     const oidcIssuer = await OIDCIssuer.discover(issuerHost);
     debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
+    const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
+    if (authorizationEndpoint) {
+      oidcIssuer.metadata.authorization_endpoint = authorizationEndpoint;
+    }
+    const tokenEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint');
+    if (tokenEndpoint) {
+      oidcIssuer.metadata.token_endpoint = tokenEndpoint;
+    }
+    const revocationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint');
+    if (revocationEndpoint) {
+      oidcIssuer.metadata.revocation_endpoint = revocationEndpoint;
+    }
+    const introspectionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint');
+    if (introspectionEndpoint) {
+      oidcIssuer.metadata.introspection_endpoint = introspectionEndpoint;
+    }
+    const userInfoEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint');
+    if (userInfoEndpoint) {
+      oidcIssuer.metadata.userinfo_endpoint = userInfoEndpoint;
+    }
+    const endSessionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint');
+    if (endSessionEndpoint) {
+      oidcIssuer.metadata.end_session_endpoint = endSessionEndpoint;
+    }
+    const registrationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint');
+    if (registrationEndpoint) {
+      oidcIssuer.metadata.registration_endpoint = registrationEndpoint;
+    }
+    const jwksUri = configManager.getConfig('crowi', 'security:passport-oidc:jwksUri');
+    if (jwksUri) {
+      oidcIssuer.metadata.jwks_uri = jwksUri;
+    }
+    debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+
     const client = new oidcIssuer.Client({
       client_id: clientId,
       client_secret: clientSecret,

+ 0 - 5
src/server/util/swigFunctions.js

@@ -113,11 +113,6 @@ module.exports = function(crowi, req, locals) {
     return crowi.passportService.getSamlMissingMandatoryConfigKeys();
   };
 
-  locals.isSearchServiceConfigured = function() {
-    const { searchService } = crowi;
-    return searchService.isConfigured;
-  };
-
   locals.isHackmdSetup = function() {
     return process.env.HACKMD_URI != null;
   };

+ 1 - 13
src/server/views/_form.html

@@ -9,18 +9,6 @@
 </div>
 {% endif %}
 
-<div class="page-editor-footer d-flex flex-row align-items-center justify-content-between">
-
-  <div>
-    <div id="page-editor-options-selector" class="d-none d-md-block"></div>
-  </div>
-
-  <div id="save-page-controls"
-    data-grant="{{ grant }}"
-    data-grant-group="{{ grantedGroupId }}"
-    data-grant-group-name="{{ grantedGroupName }}">
-  </div>
-
-</div>
+<div id="page-editor-navbar-bottom-container" class="d-none d-edit-block"></div>
 
 <div class="file-module hidden"></div>

+ 2 - 0
src/server/views/invited.html

@@ -19,6 +19,8 @@
  {% endblock %}
  {% block head_warn_alert_siteurl_undefined %}
  {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
 
 
 

+ 5 - 2
src/server/views/layout-growi/base/layout.html

@@ -9,10 +9,14 @@
 {% block layout_main %}
 
 {% block content_header_wrapper %}
-<header class="py-0 grw-header">
+<header class="py-0">
   {% block content_header %}
+    <div id="grw-subnav-container" class="d-edit-none"></div>
   {% endblock %}
 </header>
+<div id="grw-subnav-switcher-container" class="d-edit-none"></div>
+<div id="grw-subnav-sticky-trigger" class="sticky-top"></div>
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 {% endblock %}
 
 <div id="main" class="main container-fluid {% if page %}{{ css.grant(page) }}{% endif %} {% block main_css_class %}{% endblock %}">
@@ -27,6 +31,5 @@
 </div><!-- /.main -->
 
 <footer class="footer">
-  {% include '../../widget/system-version.html' %}
 </footer>
 {% endblock %} {# layout_main #}

+ 0 - 5
src/server/views/layout-growi/forbidden.html

@@ -1,11 +1,6 @@
 {% extends 'base/layout.html' %}
 
 
-{% block content_header %}
-  {% include 'widget/header.html' with {forbidden: true} %}
-{% endblock %}
-
-
 {% block content_main_before %}
   {% include '../widget/page_alerts.html' %}
 {% endblock %}

+ 0 - 5
src/server/views/layout-growi/not_creatable.html

@@ -1,11 +1,6 @@
 {% extends 'base/layout.html' %}
 
 
-{% block content_header %}
-  {% include 'widget/header.html' with {isCreatable: false} %}
-{% endblock %}
-
-
 {% block content_main_before %}
   {% include '../widget/page_alerts.html' %}
 {% endblock %}

+ 0 - 5
src/server/views/layout-growi/not_found.html

@@ -1,11 +1,6 @@
 {% extends 'base/layout.html' %}
 
 
-{% block content_header %}
-  {% include 'widget/header.html' %}
-{% endblock %}
-
-
 {% block content_main_before %}
   {% include '../widget/page_alerts.html' %}
 {% endblock %}

+ 4 - 11
src/server/views/layout-growi/page.html

@@ -1,11 +1,6 @@
 {% extends 'base/layout.html' %}
 
 
-{% block content_header %}
-  {% include 'widget/header.html' %}
-{% endblock %}
-
-
 {% block content_main_before %}
 {% endblock %}
 
@@ -17,6 +12,10 @@
 
       {% include '../widget/page_content.html' %}
 
+      <div class="page-list d-edit-none d-print-none mt-5">
+        {% include '../widget/page_list_and_timeline.html' %}
+      </div>
+
     </div>
 
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
@@ -27,12 +26,6 @@
     </div>
 
   </div>
-
-  <div class="row page-list d-edit-none d-print-none mt-5">
-    <div class="col-md-10">
-      {% include '../widget/page_list_and_timeline.html' %}
-    </div>
-  </div>
 {% endblock %}
 
 

+ 5 - 11
src/server/views/layout-growi/page_list.html

@@ -1,11 +1,6 @@
 {% extends 'base/layout.html' %}
 
 
-{% block content_header %}
-  {% include 'widget/header.html' %}
-{% endblock %}
-
-
 {% block content_main_before %}
 {% endblock %}
 
@@ -17,6 +12,10 @@
 
       {% include '../widget/page_content.html' %}
 
+      <div class="page-list d-edit-none d-print-none mt-5">
+        {% include '../widget/page_list_and_timeline.html' %}
+      </div>
+
     </div>
 
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
@@ -24,15 +23,10 @@
       <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
         <div id="revision-toc-content" class="revision-toc-content"></div>
       </div>
-    </div> {# /.col- #}
+    </div>
 
   </div>
 
-  <div class="row page-list d-edit-none d-print-none {% if page.isTopPage() %}mt-5{% endif %}">
-    <div class="col-md-10">
-      {% include '../widget/page_list_and_timeline.html' %}
-    </div>
-  </div>
 {% endblock %}
 
 

+ 0 - 11
src/server/views/layout-growi/user_page.html

@@ -5,17 +5,6 @@
   user-page
 {% endblock %}
 
-{% block content_header_wrapper %}
-  <header class="py-0 grw-header grw-header-user-page">
-    {% if pageUser %}
-      <div id="grw-subnav-for-user-page" class="grw-subnav" data-page-user="{{ pageUser|json }}"></div>
-    {% else %}
-      {% parent %}
-    {% endif %}
-  </header>
-{% endblock %}
-
-
 {% block content_main %}
   <div class="row">
 

+ 0 - 1
src/server/views/layout-growi/widget/header.html

@@ -1 +0,0 @@
-<div id="grw-subnav" class="grw-subnav d-edit-none" data-is-forbidden-page="{{ forbidden }}"></div>

+ 15 - 7
src/server/views/layout-kibela/base/layout.html

@@ -9,15 +9,24 @@
 {% block layout_main %}
 <div class="container-fluid p-0">
 
+  <div id="grw-subnav-switcher-container" class="d-edit-none"></div>
+  <div id="grw-subnav-sticky-trigger" class="sticky-top"></div>
+  <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+
   <div class="row body m-0 p-0 d-print-block">
 
     <div id="main" class="main col-12 kibela-block round-corner {% if page %}{{ css.grant(page) }}{% endif %}{% block main_css_class %}{% endblock %}">
-      <div class="row grw-subnav d-edit-none d-print-block">
-        <div class="col-12 col-xl-9 col-lg-8 px-0 mx-0 bg-white kibela-border-top round-corner">
-          {% block content_header %} {% endblock %}
-        </div>
-        <div class="col-xl-3 col-lg-4"></div>
-      </div>
+      {% block content_header_wrapper %}
+        <header class="row mb-5 grw-subnav d-edit-none d-print-block round-corner">
+            <div class="col-12 px-0 mx-0">
+              {% block content_header %}
+                <div id="grw-subnav-container" class="d-edit-none"></div>
+              {% endblock %}
+            </div>
+          </header>
+        </header>
+      {% endblock %}
+
       <!-- /.grw-subnav -->
 
       {% block content_main_before %}
@@ -36,6 +45,5 @@
 <!-- /.container-fluid -->
 
 <footer class="footer">
-  {% include '../../widget/system-version.html' %}
 </footer>
 {% endblock %} {# layout_main #}

+ 0 - 6
src/server/views/layout-kibela/forbidden.html

@@ -1,11 +1,5 @@
 {% extends 'base/layout.html' %}
 
-
-{% block content_header %}
-  {% include 'widget/header.html' %}
-{% endblock %}
-
-
 {% block content_main_before %}
   {% include '../widget/page_alerts.html' %}
 {% endblock %}

+ 0 - 6
src/server/views/layout-kibela/not_creatable.html

@@ -1,11 +1,5 @@
 {% extends 'base/layout.html' %}
 
-
-{% block content_header %}
-  {% include 'widget/header.html' %}
-{% endblock %}
-
-
 {% block content_main_before %}
   {% include '../widget/page_alerts.html' %}
 {% endblock %}

+ 0 - 6
src/server/views/layout-kibela/not_found.html

@@ -1,11 +1,5 @@
 {% extends 'base/layout.html' %}
 
-
-{% block content_header %}
-  {% include 'widget/header.html' %}
-{% endblock %}
-
-
 {% block content_main_before %}
   {% include '../widget/page_alerts.html' %}
 {% endblock %}

+ 0 - 6
src/server/views/layout-kibela/page.html

@@ -1,11 +1,5 @@
 {% extends 'base/layout.html' %}
 
-
-{% block content_header %}
-  {% include 'widget/header.html' %}
-{% endblock %}
-
-
 {% block content_main_before %}
 {% endblock %}
 

+ 0 - 5
src/server/views/layout-kibela/page_list.html

@@ -1,10 +1,5 @@
 {% extends 'base/layout.html' %}
 
-{% block content_header %}
- {% include 'widget/header.html' %}
- {% endblock %}
-
-
  {% block content_main_before%}
  {% endblock %}
 

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä