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

Merge branch 'master' into support/hotkeys

# Conflicts:
#	src/client/js/base.jsx
白石誠 5 лет назад
Родитель
Сommit
4ffa830aaf
89 измененных файлов с 1416 добавлено и 924 удалено
  1. 2 2
      resource/locales/en_US/translation.json
  2. 2 2
      resource/locales/ja_JP/translation.json
  3. 4 4
      resource/locales/zh_CN/translation.json
  4. 6 10
      src/client/js/app.jsx
  5. 4 7
      src/client/js/base.jsx
  6. 34 26
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  7. 67 0
      src/client/js/components/Fab.jsx
  8. 15 11
      src/client/js/components/Navbar/DrawerToggler.jsx
  9. 10 30
      src/client/js/components/Navbar/GlobalSearch.jsx
  10. 24 21
      src/client/js/components/Navbar/GrowiNavbar.jsx
  11. 61 0
      src/client/js/components/Navbar/GrowiNavbarBottom.jsx
  12. 136 36
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  13. 0 92
      src/client/js/components/Navbar/GrowiSubNavigationForUserPage.jsx
  14. 88 0
      src/client/js/components/Navbar/GrowiSubNavigationSwitcher.jsx
  15. 0 41
      src/client/js/components/Navbar/PageCreateButton.jsx
  16. 1 1
      src/client/js/components/Navbar/PageCreator.jsx
  17. 6 1
      src/client/js/components/Navbar/RevisionAuthor.jsx
  18. 69 0
      src/client/js/components/Page/RenderTagLabels.jsx
  19. 62 0
      src/client/js/components/Page/TagEditModal.jsx
  20. 0 71
      src/client/js/components/Page/TagEditor.jsx
  21. 63 92
      src/client/js/components/Page/TagLabels.jsx
  22. 1 1
      src/client/js/components/PageEditor.jsx
  23. 78 0
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  24. 40 19
      src/client/js/components/PageEditor/OptionsSelector.jsx
  25. 2 2
      src/client/js/components/PageEditor/PagePathNavForEditor.jsx
  26. 41 31
      src/client/js/components/PageStatusAlert.jsx
  27. 9 3
      src/client/js/components/SavePageControls/GrantSelector.jsx
  28. 3 1
      src/client/js/components/SearchForm.jsx
  29. 9 4
      src/client/js/components/Sidebar.jsx
  30. 1 1
      src/client/js/services/EditorContainer.js
  31. 16 2
      src/client/js/services/NavigationContainer.js
  32. 3 1
      src/client/js/services/PageContainer.js
  33. 9 4
      src/client/styles/scss/_admin.scss
  34. 31 5
      src/client/styles/scss/_layout.scss
  35. 0 4
      src/client/styles/scss/_login.scss
  36. 6 1
      src/client/styles/scss/_mixins.scss
  37. 12 5
      src/client/styles/scss/_navbar.scss
  38. 2 9
      src/client/styles/scss/_navbar_kibela.scss
  39. 56 58
      src/client/styles/scss/_on-edit.scss
  40. 5 0
      src/client/styles/scss/_override-bootstrap.scss
  41. 8 0
      src/client/styles/scss/_override-rbt.scss
  42. 38 0
      src/client/styles/scss/_page.scss
  43. 29 31
      src/client/styles/scss/_search.scss
  44. 60 30
      src/client/styles/scss/_sidebar.scss
  45. 72 24
      src/client/styles/scss/_subnav.scss
  46. 4 17
      src/client/styles/scss/_tag.scss
  47. 3 31
      src/client/styles/scss/_user.scss
  48. 4 0
      src/client/styles/scss/_variables.scss
  49. 4 4
      src/client/styles/scss/atoms/_buttons.scss
  50. 3 2
      src/client/styles/scss/atoms/_nav.scss
  51. 34 9
      src/client/styles/scss/theme/_apply-colors-dark.scss
  52. 3 4
      src/client/styles/scss/theme/_apply-colors-kibela.scss
  53. 27 4
      src/client/styles/scss/theme/_apply-colors-light.scss
  54. 5 1
      src/client/styles/scss/theme/_apply-colors.scss
  55. 23 0
      src/client/styles/scss/theme/_reboot-bootstrap-border-colors.scss
  56. 3 0
      src/client/styles/scss/theme/_reboot-bootstrap-theme-colors.scss
  57. 11 3
      src/client/styles/scss/theme/default.scss
  58. 2 2
      src/client/styles/scss/theme/nature.scss
  59. 1 0
      src/server/models/config.js
  60. 47 0
      src/server/routes/apiv3/users.js
  61. 0 5
      src/server/util/swigFunctions.js
  62. 3 0
      src/server/views/_form.html
  63. 2 0
      src/server/views/invited.html
  64. 5 2
      src/server/views/layout-growi/base/layout.html
  65. 0 5
      src/server/views/layout-growi/forbidden.html
  66. 0 5
      src/server/views/layout-growi/not_creatable.html
  67. 0 5
      src/server/views/layout-growi/not_found.html
  68. 4 11
      src/server/views/layout-growi/page.html
  69. 5 11
      src/server/views/layout-growi/page_list.html
  70. 0 11
      src/server/views/layout-growi/user_page.html
  71. 0 1
      src/server/views/layout-growi/widget/header.html
  72. 15 7
      src/server/views/layout-kibela/base/layout.html
  73. 0 6
      src/server/views/layout-kibela/forbidden.html
  74. 0 6
      src/server/views/layout-kibela/not_creatable.html
  75. 0 6
      src/server/views/layout-kibela/not_found.html
  76. 0 6
      src/server/views/layout-kibela/page.html
  77. 0 5
      src/server/views/layout-kibela/page_list.html
  78. 0 8
      src/server/views/layout-kibela/user_page.html
  79. 0 2
      src/server/views/layout-kibela/widget/header.html
  80. 7 13
      src/server/views/layout/layout.html
  81. 2 0
      src/server/views/login.html
  82. 0 1
      src/server/views/search.html
  83. 1 2
      src/server/views/tags.html
  84. 1 0
      src/server/views/widget/header.html
  85. 1 1
      src/server/views/widget/not_found_content.html
  86. 3 1
      src/server/views/widget/page_content.html
  87. 7 7
      src/server/views/widget/page_tabs.html
  88. 1 1
      src/server/views/widget/system-version.html
  89. 0 36
      src/server/views/widget/user_page_header.html

+ 2 - 2
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": {

+ 2 - 2
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": {

+ 4 - 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": "家在最新",
@@ -339,8 +338,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": "创建/编辑模板页",
@@ -721,4 +721,4 @@
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
 	}
-}
+}

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

+ 4 - 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 Hotkeys from './components/Hotkeys/Hotkeys';
+import Fab from './components/Fab';
 
 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,17 +39,15 @@ 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 />,
 
   hotkeys: <Hotkeys />,
 
+  'grw-fab-container': <Fab />,
 };
 
 export { appContainer, componentMappings };

+ 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();
-  };
+  }, []);
+
+  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);
+    };
+  }, []);
+
+  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>
   );
 };

+ 41 - 31
src/client/js/components/PageStatusAlert.jsx

@@ -30,10 +30,6 @@ class PageStatusAlert extends React.Component {
     this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
   }
 
-  componentWillMount() {
-    this.props.appContainer.registerComponentInstance('PageStatusAlert', this);
-  }
-
   refreshPage() {
     window.location.reload();
   }
@@ -41,15 +37,19 @@ class PageStatusAlert extends React.Component {
   renderSomeoneEditingAlert() {
     const { t } = this.props;
     return (
-      <div className="alert-hackmd-someone-editing alert alert-success fixed-bottom p-3 mb-0">
-        <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 className="card grw-page-status-alert text-white bg-success d-hackmd-none fixed-bottom">
+        <div className="card-body">
+          <p className="card-text grw-card-label-container">
+            <i className="icon-fw icon-people"></i>
+            {t('hackmd.someone_editing')}
+          </p>
+          <p className="card-text grw-card-btn-container">
+            <a href="#hackmd" className="btn btn-outline-white">
+              <i className="fa fa-fw fa-file-text-o"></i>
+              Open HackMD Editor
+            </a>
+          </p>
+        </div>
       </div>
     );
   }
@@ -57,15 +57,19 @@ class PageStatusAlert extends React.Component {
   renderDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     return (
-      <div className="alert-hackmd-draft-exists alert alert-success fixed-bottom p-3 mb-0">
-        <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 className="card grw-page-status-alert text-white bg-success d-hackmd-none fixed-bottom">
+        <div className="card-body">
+          <p className="card-text grw-card-label-container">
+            <i className="icon-fw icon-pencil"></i>
+            {t('hackmd.this_page_has_draft')}
+          </p>
+          <p className="card-text grw-card-btn-container">
+            <a href="#hackmd" className="btn btn-outline-white">
+              <i className="fa fa-fw fa-file-text-o"></i>
+              Open HackMD Editor
+            </a>
+          </p>
+        </div>
       </div>
     );
   }
@@ -76,15 +80,19 @@ class PageStatusAlert extends React.Component {
     const label2 = t('Load latest');
 
     return (
-      <div className="alert alert-warning fixed-bottom p-3 mb-0">
-        <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 className="card grw-page-status-alert text-white bg-warning fixed-bottom">
+        <div className="card-body">
+          <p className="card-text grw-card-label-container">
+            <i className="icon-fw icon-bulb"></i>
+            {this.props.pageContainer.state.lastUpdateUsername} {label1}
+          </p>
+          <p className="card-text grw-card-btn-container">
+            <a href="#" className="btn btn-outline-white" onClick={this.refreshPage}>
+              <i className="icon-fw icon-reload"></i>
+              {label2}
+            </a>
+          </p>
+        </div>
       </div>
     );
   }
@@ -112,6 +120,8 @@ class PageStatusAlert extends React.Component {
       content = this.renderDraftExistsAlert();
     }
 
+    content = this.renderUpdatedAlert();
+
     return content;
   }
 

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

+ 1 - 1
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') || '',

+ 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 {
 
@@ -73,7 +73,7 @@ export default class NavigationContainer extends Container {
       const currentYOffset = window.pageYOffset;
 
       // original throttling
-      if (scrollThresForThrottling < currentYOffset) {
+      if (SCROLL_THRES_SKIP < currentYOffset) {
         return;
       }
 
@@ -151,4 +151,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);
+    }
   }
 }
 

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

+ 60 - 30
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 + 1;
+
+  // 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

+ 72 - 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;
-}
+.grw-subnav {
+  padding: 10px 15px;
 
-/*
- * Styles
- */
-
-.grw-header {
-  .title {
-    padding: 0.5rem 15px;
-
-    line-height: 1em;
-
-    @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,50 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
       }
     }
   }
+
+  /*
+   * Compact Mode
+   */
+  &.grw-subnav-compact {
+    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'),
     };

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

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

+ 3 - 0
src/server/views/_form.html

@@ -9,6 +9,8 @@
 </div>
 {% endif %}
 
+<div id="page-editor-navbar-bottom-container" class="d-none d-edit-block"></div>
+{#
 <div class="page-editor-footer d-flex flex-row align-items-center justify-content-between">
 
   <div>
@@ -22,5 +24,6 @@
   </div>
 
 </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 %}
 

+ 0 - 8
src/server/views/layout-kibela/user_page.html

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

+ 0 - 2
src/server/views/layout-kibela/widget/header.html

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

+ 7 - 13
src/server/views/layout/layout.html

@@ -85,29 +85,23 @@
 
     <div class="flex-fill mw-0">
       {% block head_warn_alert_siteurl_undefined %}{% include '../widget/alert_siteurl_undefined.html' %}{% endblock %}
-
-      {# Search #}
-      {% if isSearchServiceConfigured() %}
-        <div id="grw-search-top" class="search-top" role="search"></div>
-      {% endif %}
-
       {% block layout_main %}{% endblock %}
     </div>
   </div>
 
+  <div id="grw-navbar-bottom-container"></div>
+
 </div><!-- /#wrapper -->
 
-{% if user %}
-  <div class="grw-fixed-controls-container d-md-none d-edit-none animated fadeInUp faster">
-    <div class="grw-fixed-controls-button-container rounded-circle">
-      <div id='create-page-button-icon'></div>
-    </div>
-  </div>
-{% endif %}
+{% block fixed-controls %}
+<div id="grw-fab-container"></div>
+{% endblock %}
 
 <!-- /#hotkeys -->
 <div id="hotkeys"></div>
 
+{% include '../widget/system-version.html' %}
+
 <div id="page-create-modal"></div>
 {% include '../modal/shortcuts.html' %}
 

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

@@ -19,6 +19,8 @@
 {% endblock %}
 {% block head_warn_alert_siteurl_undefined %}
 {% endblock %}
+{% block fixed-controls %}
+{% endblock %}
 
 {% block html_additional_headers %}
   <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>

+ 0 - 1
src/server/views/search.html

@@ -23,6 +23,5 @@
 </div><!-- /.container-fluid -->
 
 <footer class="footer">
-  {% include 'widget/system-version.html' %}
 </footer>
 {% endblock %} {# layout_main #}

+ 1 - 2
src/server/views/tags.html

@@ -5,7 +5,7 @@
 {% block html_base_css %}tags-page{% endblock %}
 
 {% block layout_main %}
-<header class="py-0 grw-header">
+<header class="py-0">
   <h1 class="title">{{ t('Tags') }}</h1>
 </header>
 
@@ -18,6 +18,5 @@
 </div><!-- /.container-fluid -->
 
 <footer class="footer">
-  {% include 'widget/system-version.html' %}
 </footer>
 {% endblock %} {# layout_main #}

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

@@ -0,0 +1 @@
+<div id="grw-subnav" class="grw-subnav d-edit-none"></div>

+ 1 - 1
src/server/views/widget/not_found_content.html

@@ -57,5 +57,5 @@
 
   </div>
 
-  <div id="page-status-alert"></div>
+  <div id="grw-page-status-alert-container"></div>
 </div>

+ 3 - 1
src/server/views/widget/page_content.html

@@ -11,6 +11,7 @@
   data-page-has-draft-on-hackmd="{% if hasDraftOnHackmd %}{{ hasDraftOnHackmd.toString() }}{% endif %}"
   data-page-is-liked="{% if user %}{{ page.isLiked(user) }}{% else %}false{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  data-page-is-forbidden="{% if forbidden %}true{% else %}false{% endif %}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
@@ -19,6 +20,7 @@
   data-page-creator="{% if page %}{{ page.creator|json }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
+  data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main"
@@ -69,5 +71,5 @@
 
   </div>
 
-  <div id="page-status-alert"></div>
+  <div id="grw-page-status-alert-container"></div>
 </div>

+ 7 - 7
src/server/views/widget/page_tabs.html

@@ -6,7 +6,7 @@
   #}
   <li class="nav-item grw-main-nav-item-left">
     <a class="nav-link active" href="#revision-body" role="tab" data-toggle="tab">
-      <i class="icon-control-play icon-fw"></i><span class="d-none d-sm-inline">View</span>
+      <i class="icon-control-play icon-fw"></i><span class="d-none d-md-inline">View</span>
     </a>
   </li>
 
@@ -19,7 +19,7 @@
           data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
         {% endif %}
       >
-        <i class="icon-note icon-fw"></i><span class="d-none d-sm-inline">{{ t('Edit') }}</span>
+        <i class="icon-note icon-fw"></i><span class="d-none d-md-inline">{{ t('Edit') }}</span>
       </a>
     </li>
 
@@ -32,7 +32,7 @@
           data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
         {% endif %}
       >
-        <i class="fa fa-fw fa-file-text-o"></i><span class="d-none d-sm-inline">{{ t('HackMD') }}</span>
+        <i class="fa fa-fw fa-file-text-o"></i><span class="d-none d-md-inline">{{ t('HackMD') }}</span>
       </a>
     </li>
     {% endif %}
@@ -49,15 +49,15 @@
 
   <!-- presentation -->
   {% if not page.isTopPage() %}
-    <li class="nav-item">
+    <li class="nav-item d-edit-none">
       <a href="?presentation=1" class="nav-link toggle-presentation">
-        <i class="icon-film icon-fw"></i><span class="d-none d-md-inline">{{ t('Presentation Mode') }}</span>
+        <i class="icon-film icon-fw"></i><span class="d-none d-sm-inline">{{ t('Presentation Mode') }}</span>
       </a>
     </li>
   {% endif %}
 
   <!-- revision-history -->
-  <li class="nav-item">
+  <li class="nav-item d-edit-none">
     <a class="nav-link" href="#revision-history" role="tab" data-toggle="tab">
       <i class="icon-layers icon-fw"></i><span class="d-none d-md-inline">{{ t('History') }}</span>
     </a>
@@ -65,7 +65,7 @@
 
   <!-- icon-options-vertical -->
   {% if !isTrashPage() %}
-    <li id="page-management" class="nav-item dropdown"></li>
+    <li id="page-management" class="nav-item dropdown d-edit-none"></li>
   {% endif %}
 </ul>
 

+ 1 - 1
src/server/views/widget/system-version.html

@@ -1,4 +1,4 @@
-<div class="system-version d-none d-md-block d-print-none">
+<div class="system-version d-none d-md-block d-edit-none d-print-none">
   <span>
     <a href="https://growi.org">GROWI</a> {{ growiVersion() }}
   </span>

+ 0 - 36
src/server/views/widget/user_page_header.html

@@ -1,36 +0,0 @@
-<div class="header-wrap">
-  <header id="page-header" class="user-page-header">
-
-    <h4 id="revision-path"></h4>
-
-    <div class="users-info d-flex align-items-center">
-      <img src="{{ pageUser.imageUrlCached }}" class="picture img-circle">
-      <div class="users-meta" style="flex: 1;">
-        <div class="d-flex align-items-center">
-          <h1>
-            {{ pageUser.name }}
-          </h1>
-        </div>
-        <div class="user-page-meta">
-          <ul>
-            <li class="user-page-username"><i class="icon-user"></i> {{ pageUser.username }}</li>
-            <li class="user-page-email">
-              <i class="icon-envelope"></i>
-              {% if pageUser.isEmailPublished %}
-                {{ pageUser.email }}
-              {% else %}
-                *****
-              {% endif %}
-            </li>
-            {% if pageUser.introduction %}
-            <li class="user-page-introduction"><p>{{ pageUser.introduction|nl2br }}</p></li>
-            {% endif %}
-          </ul>
-        </div>
-      </div>
-      <div class="d-flex">
-        {% include 'header-buttons-lg.html' %}
-      </div>
-    </div>
-  </header>
-</div>