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

Merge branch 'master' into imprv/display-pageaccessoriesmodal-in-case-of-mobile

zamis 5 лет назад
Родитель
Сommit
2d39eae85d
40 измененных файлов с 597 добавлено и 473 удалено
  1. 1 0
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  2. 2 2
      src/client/js/components/Admin/Notification/NotificationSetting.jsx
  3. 8 2
      src/client/js/components/Admin/Security/SecurityManagementContents.jsx
  4. 3 2
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  5. 5 6
      src/client/js/components/BookmarkButton.jsx
  6. 0 164
      src/client/js/components/CustomNavigation.jsx
  7. 231 0
      src/client/js/components/CustomNavigation/CustomNav.jsx
  8. 52 0
      src/client/js/components/CustomNavigation/CustomNavAndContents.jsx
  9. 37 0
      src/client/js/components/CustomNavigation/CustomTabContent.jsx
  10. 2 2
      src/client/js/components/ForbiddenPage.jsx
  11. 2 2
      src/client/js/components/LikeButton.jsx
  12. 3 2
      src/client/js/components/Me/PersonalSettings.jsx
  13. 11 27
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  14. 110 0
      src/client/js/components/Navbar/PageEditorModeManager.jsx
  15. 0 156
      src/client/js/components/Navbar/ThreeStrandedButton.jsx
  16. 2 2
      src/client/js/components/NotFoundPage.jsx
  17. 13 8
      src/client/js/components/PageAccessoriesModal.jsx
  18. 2 2
      src/client/js/components/PageComment/CommentEditor.jsx
  19. 2 2
      src/client/js/components/TrashPageList.jsx
  20. 9 9
      src/client/js/services/PageContainer.js
  21. 2 45
      src/client/styles/scss/_mixins.scss
  22. 11 8
      src/client/styles/scss/_navbar.scss
  23. 32 0
      src/client/styles/scss/molecules/page-editor-mode-manager.scss
  24. 1 0
      src/client/styles/scss/style-app.scss
  25. 1 1
      src/client/styles/scss/theme/_apply-colors-dark.scss
  26. 1 1
      src/client/styles/scss/theme/_apply-colors-light.scss
  27. 4 2
      src/client/styles/scss/theme/_apply-colors.scss
  28. 2 2
      src/client/styles/scss/theme/antarctic.scss
  29. 2 2
      src/client/styles/scss/theme/christmas.scss
  30. 4 4
      src/client/styles/scss/theme/default.scss
  31. 2 2
      src/client/styles/scss/theme/future.scss
  32. 2 2
      src/client/styles/scss/theme/halloween.scss
  33. 2 2
      src/client/styles/scss/theme/island.scss
  34. 2 2
      src/client/styles/scss/theme/kibela.scss
  35. 22 0
      src/client/styles/scss/theme/mixins/_page-editor-mode-manager.scss
  36. 4 4
      src/client/styles/scss/theme/mono-blue.scss
  37. 2 2
      src/client/styles/scss/theme/nature.scss
  38. 2 2
      src/client/styles/scss/theme/spring.scss
  39. 2 2
      src/client/styles/scss/theme/wood.scss
  40. 2 2
      src/server/routes/apiv3/user-group.js

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

@@ -81,6 +81,7 @@ const AdminNavigation = (props) => {
           className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
           type="button"
           id="dropdown-admin-navigation"
+          data-display="static"
           data-toggle="dropdown"
           aria-haspopup="true"
           aria-expanded="false"

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

@@ -11,7 +11,7 @@ import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
 
-import { CustomNav } from '../../CustomNavigation';
+import { CustomNavTab } from '../../CustomNavigation/CustomNav';
 
 import SlackAppConfiguration from './SlackAppConfiguration';
 import UserTriggerNotification from './UserTriggerNotification';
@@ -72,7 +72,7 @@ function NotificationSetting(props) {
 
   return (
     <>
-      <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
+      <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
 
       <TabContent activeTab={activeTab} className="p-5">
         <TabPane tabId="slack_configuration">

+ 8 - 2
src/client/js/components/Admin/Security/SecurityManagementContents.jsx

@@ -16,7 +16,7 @@ import TwitterSecuritySetting from './TwitterSecuritySetting';
 import FacebookSecuritySetting from './FacebookSecuritySetting';
 import ShareLinkSetting from './ShareLinkSetting';
 
-import { CustomNav } from '../../CustomNavigation';
+import CustomNav from '../../CustomNavigation/CustomNav';
 
 function SecurityManagementContents(props) {
   const { t } = props;
@@ -104,7 +104,13 @@ function SecurityManagementContents(props) {
 
       <div className="auth-mechanism-configurations">
         <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
-        <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
+        <CustomNav
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          onNavSelected={switchActiveTab}
+          hideBorderBottom
+          breakpointToSwitchDropdownDown="md"
+        />
         <TabContent activeTab={activeTab} className="p-5">
           <TabPane tabId="passport_local">
             {activeComponents.has('passport_local') && <LocalSecuritySetting />}

+ 3 - 2
src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx

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

+ 5 - 6
src/client/js/components/BookmarkButton.jsx

@@ -35,8 +35,8 @@ class BookmarkButton extends React.Component {
 
 
   render() {
-    const { pageContainer, t } = this.props;
-    const isGuestUser = pageContainer.state.isGuestUser;
+    const { appContainer, pageContainer, t } = this.props;
+    const { isGuestUser } = appContainer;
 
     return (
       <div>
@@ -45,8 +45,7 @@ class BookmarkButton extends React.Component {
           id="bookmark-button"
           onClick={this.handleClick}
           className={`btn btn-bookmark border-0
-          ${`btn-${this.props.size}`}
-          ${pageContainer.state.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          ${`btn-${this.props.size}`} ${pageContainer.state.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
           <i className="icon-star mr-3"></i>
           <span className="total-bookmarks">
@@ -71,8 +70,8 @@ class BookmarkButton extends React.Component {
 const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer, PageContainer]);
 
 BookmarkButton.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  appContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   pageId: PropTypes.string,
   t: PropTypes.func.isRequired,

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

@@ -1,164 +0,0 @@
-import React, {
-  useEffect, useState, useRef, useMemo, useCallback,
-} from 'react';
-import PropTypes from 'prop-types';
-import {
-  Nav, NavItem, NavLink, TabContent, TabPane,
-} from 'reactstrap';
-
-
-export const CustomNav = (props) => {
-  const navContainer = useRef();
-  const [sliderWidth, setSliderWidth] = useState(0);
-  const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
-
-  const {
-    activeTab, navTabMapping, onNavSelected, hideBorderBottom,
-  } = props;
-
-  const navTabRefs = useMemo(() => {
-    const obj = {};
-    Object.keys(navTabMapping).forEach((key) => {
-      obj[key] = React.createRef();
-    });
-    return obj;
-  }, [navTabMapping]);
-
-  const navLinkClickHandler = useCallback((key) => {
-    if (onNavSelected != null) {
-      onNavSelected(key);
-    }
-  }, [onNavSelected]);
-
-  function registerNavLink(key, elm) {
-    if (elm != null) {
-      navTabRefs[key] = elm;
-    }
-  }
-
-  // Might make this dynamic for px, %, pt, em
-  function getPercentage(min, max) {
-    return min / max * 100;
-  }
-
-  useEffect(() => {
-    if (activeTab === '') {
-      return;
-    }
-
-    if (navContainer == null) {
-      return;
-    }
-
-    let tempML = 0;
-
-    const styles = Object.entries(navTabRefs).map((el) => {
-      const width = getPercentage(el[1].offsetWidth, navContainer.current.offsetWidth);
-      const marginLeft = tempML;
-      tempML += width;
-      return { width, marginLeft };
-    });
-    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
-
-    setSliderWidth(width);
-    setSliderMarginLeft(marginLeft);
-
-  }, [activeTab, navTabRefs, navTabMapping]);
-
-  return (
-    <div className="grw-custom-nav">
-      <div ref={navContainer}>
-        <Nav className="nav-title">
-          {Object.entries(navTabMapping).map(([key, value]) => {
-
-            const isActive = activeTab === key;
-            const isLinkEnabled = value.isLinkEnabled != null ? value.isLinkEnabled(value) : true;
-            const { Icon, i18n } = value;
-
-            return (
-              <NavItem
-                key={key}
-                className={`p-0 grw-custom-navtab ${isActive && 'active'}`}
-              >
-                <NavLink type="button" key={key} innerRef={elm => registerNavLink(key, elm)} disabled={!isLinkEnabled} onClick={() => navLinkClickHandler(key)}>
-                  <Icon /> {i18n}
-                </NavLink>
-              </NavItem>
-            );
-          })}
-        </Nav>
-      </div>
-      <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
-      { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
-    </div>
-  );
-
-};
-
-CustomNav.propTypes = {
-  activeTab: PropTypes.string.isRequired,
-  navTabMapping: PropTypes.object.isRequired,
-  onNavSelected: PropTypes.func,
-  hideBorderBottom: PropTypes.bool,
-};
-
-CustomNav.defaultProps = {
-  hideBorderBottom: false,
-};
-
-
-export const CustomTabContent = (props) => {
-
-  const { activeTab, navTabMapping, additionalClassNames } = props;
-
-  return (
-    <TabContent activeTab={activeTab} className={additionalClassNames.join(' ')}>
-      {Object.entries(navTabMapping).map(([key, value]) => {
-
-        const { Content } = value;
-
-        return (
-          <TabPane key={key} tabId={key}>
-            <Content />
-          </TabPane>
-        );
-      })}
-    </TabContent>
-  );
-
-};
-
-CustomTabContent.propTypes = {
-  activeTab: PropTypes.string.isRequired,
-  navTabMapping: PropTypes.object.isRequired,
-  additionalClassNames: PropTypes.arrayOf(PropTypes.string),
-};
-CustomTabContent.defaultProps = {
-  additionalClassNames: [],
-};
-
-
-const CustomNavigation = (props) => {
-  const { navTabMapping, defaultTabIndex, tabContentClasses } = props;
-  const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
-
-  return (
-    <React.Fragment>
-
-      <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={setActiveTab} />
-      <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
-
-    </React.Fragment>
-  );
-};
-
-CustomNavigation.propTypes = {
-  navTabMapping: PropTypes.object.isRequired,
-  defaultTabIndex: PropTypes.number,
-  tabContentClasses: PropTypes.arrayOf(PropTypes.string),
-};
-CustomNavigation.defaultProps = {
-  tabContentClasses: ['p-4'],
-};
-
-export default CustomNavigation;

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

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

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

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

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

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

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

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
-import CustomNavigation from './CustomNavigation';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import PageList from './PageList';
 
 
@@ -41,7 +41,7 @@ const ForbiddenPage = (props) => {
         </div>
       </div>
       <div className="mt-5">
-        <CustomNavigation navTabMapping={navTabMapping} />
+        <CustomNavAndContents navTabMapping={navTabMapping} />
       </div>
     </>
   );

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

@@ -35,8 +35,8 @@ class LikeButton extends React.Component {
 
 
   render() {
-    const { pageContainer, t } = this.props;
-    const isGuestUser = pageContainer.state.isGuestUser;
+    const { appContainer, pageContainer, t } = this.props;
+    const { isGuestUser } = appContainer;
 
     return (
       <div>

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

@@ -2,7 +2,8 @@
 import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import CustomNavigation from '../CustomNavigation';
+
+import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import UserSettings from './UserSettings';
 import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
@@ -43,7 +44,7 @@ const PersonalSettings = (props) => {
 
 
   return (
-    <CustomNavigation navTabMapping={navTabMapping} tabContentClasses={['px-0']} />
+    <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
   );
 
 };

+ 11 - 27
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -16,7 +16,7 @@ import RevisionPathControls from '../Page/RevisionPathControls';
 import TagLabels from '../Page/TagLabels';
 import LikeButton from '../LikeButton';
 import BookmarkButton from '../BookmarkButton';
-import { TwoStrandedButton, ThreeStrandedButton } from './ThreeStrandedButton';
+import PageEditorModeManager from './PageEditorModeManager';
 
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
@@ -88,39 +88,20 @@ const GrowiSubNavigation = (props) => {
   const {
     appContainer, navigationContainer, pageContainer, isCompactMode,
   } = props;
-  const { isDrawerMode, editorMode } = navigationContainer.state;
+  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
 
-  const { isGuestUser, isMobile } = appContainer;
+  const { isGuestUser } = appContainer;
   const isEditorMode = editorMode !== 'view';
   // Tags cannot be edited while the new page and editorMode is view
   const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
 
-  function onThreeStrandedButtonClicked(viewType) {
+  function onPageEditorModeButtonClicked(viewType) {
     navigationContainer.setEditorMode(viewType);
   }
 
-  function renderThreeStrandedButton() {
-    return (
-      <ThreeStrandedButton
-        onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}
-        isBtnDisabled={isGuestUser}
-        editorMode={editorMode}
-      />
-    );
-  }
-  function renderTwoStrandedButton() {
-    return (
-      <TwoStrandedButton
-        onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}
-        isBtnDisabled={isGuestUser}
-        editorMode={editorMode}
-      />
-    );
-  }
-
   return (
     <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
 
@@ -151,10 +132,13 @@ const GrowiSubNavigation = (props) => {
             { pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} /> }
           </div>
           <div className={`${isEditorMode ? 'ml-2' : 'mt-2'}`}>
-            {pageContainer.isAbleToShowThreeStrandedButton && (
-              <>
-                {isMobile ? renderTwoStrandedButton() : renderThreeStrandedButton()}
-              </>
+            {pageContainer.isAbleToShowPageEditorModeManager && (
+              <PageEditorModeManager
+                onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+                isBtnDisabled={isGuestUser}
+                editorMode={editorMode}
+                isDeviceSmallerThanMd={isDeviceSmallerThanMd}
+              />
             )}
           </div>
         </div>

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

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

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

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

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

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import CustomNavigation from './CustomNavigation';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import PageList from './PageList';
 import PageTimeline from './PageTimeline';
 
@@ -30,7 +30,7 @@ const NotFoundPage = (props) => {
 
   return (
     <div className="mt-5 d-edit-none">
-      <CustomNavigation navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
   );
 };

+ 13 - 8
src/client/js/components/PageAccessoriesModal.jsx

@@ -19,7 +19,7 @@ import PageTimeline from './PageTimeline';
 import PageList from './PageList';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
-import { CustomNav } from './CustomNavigation';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
 import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
@@ -80,17 +80,16 @@ const PageAccessoriesModal = (props) => {
   };
 
   const buttons = (
-    <span>
-      {/* change order because of `float: right` by '.close' class */}
-      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
+    <div className="d-flex flex-nowrap">
       <ExpandOrContractButton
         isWindowExpanded={isWindowExpanded}
         expandWindow={expandWindow}
         contractWindow={contractWindow}
       />
-    </span>
+      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
   );
 
   return (
@@ -102,7 +101,13 @@ const PageAccessoriesModal = (props) => {
         className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
       >
         <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
-          <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={switchActiveTab}
+            breakpointToHideInactiveTabsDown="md"
+            hideBorderBottom
+          />
         </ModalHeader>
         <ModalBody className="overflow-auto grw-modal-body-style p-0">
           {/* Do not use CustomTabContent because of performance problem:

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

@@ -21,7 +21,7 @@ import SlackNotification from '../SlackNotification';
 
 import CommentPreview from './CommentPreview';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-import { CustomNav } from '../CustomNavigation';
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
 
 const navTabMapping = {
   comment_editor: {
@@ -298,7 +298,7 @@ class CommentEditor extends React.Component {
     return (
       <>
         <div className="comment-write">
-          <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
+          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
               <Editor

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

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
-import CustomNavigation from './CustomNavigation';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import PageList from './PageList';
 
 
@@ -22,7 +22,7 @@ const TrashPageList = (props) => {
 
   return (
     <div className="mt-5 d-edit-none">
-      <CustomNavigation navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
   );
 };

+ 9 - 9
src/client/js/services/PageContainer.js

@@ -180,26 +180,26 @@ export default class PageContainer extends Container {
    * ex.) duplicate, rename
    */
   get isAbleToShowPageManagement() {
-    const { isPageExist, isPageInTrash } = this.state;
+    const { isPageExist, isTrashPage } = this.state;
     const { isSharedUser } = this.appContainer;
 
-    return (isPageExist && !isPageInTrash && !isSharedUser);
+    return (isPageExist && !isTrashPage && !isSharedUser);
   }
 
   /**
-   * whether to threeStrandedButton
+   * whether to display pageEditorModeManager
    * ex.) view, edit, hackmd
    */
-  get isAbleToShowThreeStrandedButton() {
-    const { isNotCreatable, isPageInTrash } = this.state;
-    const { isSharedUser, isGuestUser } = this.appContainer;
+  get isAbleToShowPageEditorModeManager() {
+    const { isNotCreatable, isTrashPage } = this.state;
+    const { isSharedUser } = this.appContainer;
 
-    return (!isNotCreatable && !isPageInTrash && !isSharedUser && !isGuestUser);
+    return (!isNotCreatable && !isTrashPage && !isSharedUser);
   }
 
   /**
-   * whether to threeStrandedButton
-   * ex.) view, edit, hackmd
+   * whether to display pageAuthors
+   * ex.) creator, lastUpdateUser
    */
   get isAbleToShowPageAuthors() {
     const { isPageExist, isUserPage } = this.state;

+ 2 - 45
src/client/styles/scss/_mixins.scss

@@ -227,7 +227,7 @@
   transition-duration: 300ms;
 }
 
-@mixin border-vertical($beforeOrAfter, $borderColor, $borderLength, $zIndex: initial, $isBtnGroup: false) {
+@mixin border-vertical($beforeOrAfter, $borderLength, $zIndex: initial, $isBtnGroup: false) {
   position: relative;
   @if $isBtnGroup {
     &:not(:first-child) {
@@ -248,51 +248,8 @@
       height: $borderLength;
       margin-left: -0.5px;
       content: '';
-      border-left: 1px solid $borderColor;
+      border-left: 1px solid transparent;
       transition: border-color 0.15s ease-in-out;
     }
   }
 }
-
-@mixin three-stranded-button($textColor, $borderColor, $bgColorHoverAndActive, $bgColor: white) {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  width: 70px;
-  padding-right: 0;
-  padding-left: 0;
-  color: $textColor;
-  white-space: nowrap;
-  background-color: $bgColor;
-  border-color: $borderColor;
-
-  @include border-vertical('before', $borderColor, 70%, 1, true);
-
-  &.view-button,
-  &.edit-button {
-    .grw-three-stranded-button-icon {
-      margin-right: -0.25rem;
-    }
-  }
-  &.hackmd-button {
-    font-size: 12px;
-    letter-spacing: -0.6px;
-
-    .grw-three-stranded-button-icon {
-      margin-right: -0.1rem;
-    }
-  }
-  &:hover,
-  &:active,
-  &.active {
-    color: $textColor;
-    background-color: $bgColorHoverAndActive;
-    border-color: $borderColor;
-    &::after {
-      border-color: $bgColorHoverAndActive;
-    }
-  }
-  &:not(:disabled):not(.disabled):focus {
-    box-shadow: none;
-  }
-}

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

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

+ 32 - 0
src/client/styles/scss/molecules/page-editor-mode-manager.scss

@@ -0,0 +1,32 @@
+// @mixin page-editor-mode-manager($textColor, $borderColor, $bgColorHoverAndActive, $bgColor: white) {
+.grw-page-editor-mode-manager .btn {
+  width: 70px;
+  white-space: nowrap;
+
+  @include border-vertical('before', 70%, 1, true);
+
+  &.view-button,
+  &.edit-button {
+    line-height: 1.2rem;
+    .grw-page-editor-mode-manager-icon {
+      @include media-breakpoint-down(sm) {
+        font-size: 1.2rem;
+      }
+    }
+  }
+  &.hackmd-button {
+    line-height: 1.2rem;
+    .grw-page-editor-mode-manager-icon {
+      @include media-breakpoint-down(sm) {
+        font-size: 1.2rem;
+      }
+    }
+    .grw-page-editor-mode-manager-label {
+      font-size: 12px;
+      letter-spacing: -0.6px;
+    }
+  }
+  &:not(:disabled):not(.disabled):focus {
+    box-shadow: none;
+  }
+}

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -23,6 +23,7 @@
 
 // molecules
 @import 'molecules/copy-dropdown';
+@import 'molecules/page-editor-mode-manager';
 @import 'molecules/slack-notification';
 
 // growi component

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

@@ -232,7 +232,7 @@ ul.pagination {
   background-color: rgba($bgcolor-subnav, 0.85);
 }
 
-.grw-three-stranded-button {
+.grw-page-editor-mode-manager {
   .btn-outline-primary {
     &:hover {
       color: $primary;

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

@@ -152,7 +152,7 @@ $border-color: $border-color-global;
   background-color: rgba($bgcolor-subnav, 0.85);
 }
 
-.grw-three-stranded-button {
+.grw-page-editor-mode-manager {
   .btn-outline-primary {
     &:hover {
       color: $primary;

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

@@ -33,6 +33,7 @@ $nav-tabs-link-active-border-color: $bordercolor-nav-tabs-active;
 @import '~bootstrap/scss/mixins';
 @import '../mixins';
 @import 'mixins/list-group';
+@import 'mixins/page-editor-mode-manager';
 @import 'mixins/tables'; // comment out and use _reboot-bootstrap-tables instead -- 2020.05.28 Yuki Takei
 @import 'reboot-bootstrap-colors';
 @import 'reboot-bootstrap-theme-colors';
@@ -100,7 +101,8 @@ pre:not(.hljs):not(.CodeMirror-line) {
   }
 
   &:active,
-  &.active {
+  &.active,
+  &.active:hover {
     color: $color-dropdown-link-active;
     background-color: $bgcolor-dropdown-link-active;
 
@@ -326,7 +328,7 @@ ul.pagination {
   }
 }
 
-.grw-custom-nav {
+.grw-custom-nav-tab {
   .nav-item {
     &:hover,
     &:focus {

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

@@ -114,9 +114,9 @@ html[dark] {
   @import 'apply-colors-light';
 
   //Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(darken($primary, 10%), lighten($primary, 55%), lighten($primary, 60%));
+      @include btn-page-editor-mode-manager(darken($primary, 10%), lighten($primary, 55%), lighten($primary, 60%));
     }
   }
 

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

@@ -185,9 +185,9 @@ html[dark] {
   }
 
   // Button
-  .grw-three-stranded-button {
+  .grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(darken($subthemecolor, 15%), lighten($subthemecolor, 35%), lighten($subthemecolor, 45%));
+      @include btn-page-editor-mode-manager(darken($subthemecolor, 15%), lighten($subthemecolor, 35%), lighten($subthemecolor, 45%));
     }
   }
 }

+ 4 - 4
src/client/styles/scss/theme/default.scss

@@ -105,9 +105,9 @@ html[light] {
   @import 'apply-colors-light';
 
   // Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button($primary, lighten($primary, 65%), lighten($primary, 70%));
+      @include btn-page-editor-mode-manager($primary, lighten($primary, 65%), lighten($primary, 70%));
     }
   }
 }
@@ -205,9 +205,9 @@ html[dark] {
   @import 'apply-colors-dark';
 
   //Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(lighten($primary, 30%), lighten($primary, 20%), $primary, darken($primary, 20%));
+      @include btn-page-editor-mode-manager(lighten($primary, 30%), lighten($primary, 20%), $primary, darken($primary, 20%));
     }
   }
 }

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

@@ -90,9 +90,9 @@ html[dark] {
   @import 'apply-colors-dark';
 
   //Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(lighten($primary, 10%), $primary, darken($primary, 10%), darken($primary, 20%));
+      @include btn-page-editor-mode-manager(lighten($primary, 10%), $primary, darken($primary, 10%), darken($primary, 20%));
     }
   }
 

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

@@ -108,9 +108,9 @@ html[dark] {
   @import 'apply-colors-dark';
 
   //Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(lighten($primary, 35%), $primary, lighten($primary, 5%), darken($primary, 20%));
+      @include btn-page-editor-mode-manager(lighten($primary, 35%), $primary, lighten($primary, 5%), darken($primary, 20%));
     }
   }
 

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

@@ -110,9 +110,9 @@ html[dark] {
   }
 
   // Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(darken($primary, 50%), lighten($primary, 5%), darken($primary, 5%));
+      @include btn-page-editor-mode-manager(darken($primary, 50%), lighten($primary, 5%), darken($primary, 5%));
     }
   }
 }

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

@@ -109,9 +109,9 @@ html[dark] {
   @import 'apply-colors';
   @import 'apply-colors-light';
   //Button
-  .grw-three-stranded-button {
+  .grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(darken($primary, 15%), lighten($primary, 45%), lighten($primary, 50%));
+      @include btn-page-editor-mode-manager(darken($primary, 15%), lighten($primary, 45%), lighten($primary, 50%));
     }
   }
 }

+ 22 - 0
src/client/styles/scss/theme/mixins/_page-editor-mode-manager.scss

@@ -0,0 +1,22 @@
+@mixin btn-page-editor-mode-manager($textColor, $borderColor, $bgColorHoverAndActive, $bgColor: white) {
+  color: $textColor;
+  background-color: $bgColor;
+  border-color: $borderColor;
+
+  &:not(:first-child) {
+    &::before {
+      border-left-color: $borderColor;
+    }
+  }
+
+  &:hover,
+  &:active,
+  &.active {
+    color: $textColor;
+    background-color: $bgColorHoverAndActive;
+    border-color: $borderColor;
+    &::after {
+      border-color: $bgColorHoverAndActive;
+    }
+  }
+}

+ 4 - 4
src/client/styles/scss/theme/mono-blue.scss

@@ -89,9 +89,9 @@ html[light] {
     }
   }
   // Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button($primary, lighten($primary, 65%), lighten($primary, 70%));
+      @include btn-page-editor-mode-manager($primary, lighten($primary, 65%), lighten($primary, 70%));
     }
   }
 }
@@ -197,9 +197,9 @@ html[dark] {
   }
 
   // Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(lighten($primary, 30%), $primary, darken($primary, 10%), darken($primary, 20%));
+      @include btn-page-editor-mode-manager(lighten($primary, 30%), $primary, darken($primary, 10%), darken($primary, 20%));
     }
   }
 }

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

@@ -112,9 +112,9 @@ html[dark] {
   }
 
   // Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button($bgcolor-navbar, lighten($bgcolor-navbar, 65%), lighten($bgcolor-navbar, 70%));
+      @include btn-page-editor-mode-manager($bgcolor-navbar, lighten($bgcolor-navbar, 65%), lighten($bgcolor-navbar, 70%));
     }
   }
 }

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

@@ -98,9 +98,9 @@ html[dark] {
   .btn.btn-outline-primary {
     @include button-outline-variant($accentcolor, $accentcolor, lighten($accentcolor, 20%), $accentcolor);
   }
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(darken($primary, 50%), lighten($primary, 5%), lighten($primary, 10%));
+      @include btn-page-editor-mode-manager(darken($primary, 50%), lighten($primary, 5%), lighten($primary, 10%));
     }
   }
 

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

@@ -162,9 +162,9 @@ html[dark] {
   }
 
   // Button
-  .btn-group.grw-three-stranded-button {
+  .btn-group.grw-page-editor-mode-manager {
     .btn.btn-outline-primary {
-      @include three-stranded-button(darken($primary, 30%), lighten($primary, 15%), lighten($primary, 25%));
+      @include btn-page-editor-mode-manager(darken($primary, 30%), lighten($primary, 15%), lighten($primary, 25%));
     }
   }
 }

+ 2 - 2
src/server/routes/apiv3/user-group.js

@@ -578,7 +578,7 @@ module.exports = (crowi) => {
     const { limit, offset } = req.query;
 
     try {
-      const { docs, total } = await Page.paginate({
+      const { docs, totalDocs } = await Page.paginate({
         grant: Page.GRANT_USER_GROUP,
         grantedGroup: { $in: [id] },
       }, {
@@ -593,7 +593,7 @@ module.exports = (crowi) => {
       const current = offset / limit + 1;
 
       // TODO: create a common moudule for paginated response
-      return res.apiv3({ total, current, pages: docs });
+      return res.apiv3({ total: totalDocs, current, pages: docs });
     }
     catch (err) {
       const msg = `Error occurred in fetching pages for group: ${id}`;