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

Merge remote-tracking branch 'origin/master' into feat/article-area-renovation

Yuki Takei 5 лет назад
Родитель
Сommit
f43c083232
56 измененных файлов с 1117 добавлено и 224 удалено
  1. 4 0
      CHANGES.md
  2. 1 0
      config/logger/config.dev.js
  3. 1 0
      package.json
  4. 6 0
      public/images/icons/slack/slack-logo-dark-off.svg
  5. 6 0
      public/images/icons/slack/slack-logo-dark-on.svg
  6. 6 0
      public/images/icons/slack/slack-logo-off.svg
  7. 6 0
      public/images/icons/slack/slack-logo-on.svg
  8. 15 2
      resource/locales/en_US/admin/admin.json
  9. 13 2
      resource/locales/ja_JP/admin/admin.json
  10. 14 3
      resource/locales/zh_CN/admin/admin.json
  11. 4 2
      src/client/js/app.jsx
  12. 30 38
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  13. 58 0
      src/client/js/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx
  14. 32 17
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  15. 109 0
      src/client/js/components/MyBookmarkList/MyBookmarkList.jsx
  16. 3 3
      src/client/js/components/MyDraftList/MyDraftList.jsx
  17. 6 11
      src/client/js/components/PageAttachment.jsx
  18. 2 0
      src/client/js/components/PageComment/CommentEditor.jsx
  19. 59 2
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  20. 11 10
      src/client/js/components/PageHistory.jsx
  21. 4 6
      src/client/js/components/PageList.jsx
  22. 6 7
      src/client/js/components/PageTimeline.jsx
  23. 4 3
      src/client/js/components/PaginationWrapper.jsx
  24. 7 8
      src/client/js/components/RecentCreated/RecentCreated.jsx
  25. 1 24
      src/client/js/components/SavePageControls.jsx
  26. 21 0
      src/client/js/components/SlackLogo.jsx
  27. 17 15
      src/client/js/components/SlackNotification.jsx
  28. 47 7
      src/client/js/services/AdminCustomizeContainer.js
  29. 6 0
      src/client/js/services/AdminSocketIoContainer.js
  30. 4 0
      src/client/js/services/PageContainer.js
  31. 6 4
      src/client/js/services/PageHistoryContainer.js
  32. 11 0
      src/client/js/services/SocketIoContainer.js
  33. 0 20
      src/client/styles/scss/_on-edit.scss
  34. 1 0
      src/client/styles/scss/_override-bootstrap-variables.scss
  35. 54 0
      src/client/styles/scss/atoms/_custom_control.scss
  36. 42 0
      src/client/styles/scss/molecules/slack-notification.scss
  37. 1 0
      src/client/styles/scss/style-app.scss
  38. 42 0
      src/client/styles/scss/theme/_apply-colors-dark.scss
  39. 45 0
      src/client/styles/scss/theme/_apply-colors-light.scss
  40. 1 1
      src/server/crowi/index.js
  41. 7 1
      src/server/middlewares/admin-required.js
  42. 8 1
      src/server/middlewares/login-required.js
  43. 2 0
      src/server/models/bookmark.js
  44. 4 2
      src/server/models/config.js
  45. 7 5
      src/server/routes/apiv3/attachment.js
  46. 84 1
      src/server/routes/apiv3/bookmarks.js
  47. 19 5
      src/server/routes/apiv3/customize-setting.js
  48. 15 7
      src/server/routes/apiv3/pages.js
  49. 6 5
      src/server/routes/apiv3/revisions.js
  50. 8 3
      src/server/routes/apiv3/users.js
  51. 18 0
      src/server/service/config-loader.js
  52. 2 1
      src/server/service/search-delegator/elasticsearch.js
  53. 160 0
      src/server/service/socket-io.js
  54. 3 8
      src/server/views/widget/user_page_content.html
  55. 63 0
      src/test/middlewares/login-required.test.js
  56. 5 0
      yarn.lock

+ 4 - 0
CHANGES.md

@@ -12,6 +12,10 @@
 * Improvement: Basic layout of page
 * Support: Support MongoDB 4.0, 4.2 and 4.4
 
+## v4.1.9
+
+* Feature: Environment variables to set max connection size to deliver push messages to all clients
+
 ## v4.1.8
 
 * Improvement: Rebuilding progress bar colors for Full Text Search Management

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

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

+ 1 - 0
package.json

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

+ 6 - 0
public/images/icons/slack/slack-logo-dark-off.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#9BA5AF;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 6 - 0
public/images/icons/slack/slack-logo-dark-on.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#DD80DE;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 6 - 0
public/images/icons/slack/slack-logo-off.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#9ba5af;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 6 - 0
public/images/icons/slack/slack-logo-on.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 448">
+  <defs>
+    <style>.cls-1{fill:#af30b0;}</style>
+  </defs>
+  <path class="cls-1" d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"/>
+</svg>

+ 15 - 2
resource/locales/en_US/admin/admin.json

@@ -107,8 +107,21 @@
       "tab_switch_desc2": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
       "attach_title_header": "Add h1 section when create new page automatically",
       "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
-      "recent_created__n_draft_num_desc": "Number of recently created pages & drafts displayed",
-      "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
+
+      "list_num_s": "Number of list displayed on modals",
+      "list_num_desc_s": "Set number of list per page such as 'Pagelist', 'Timeline', 'Page History' and 'Share Link' pages",
+
+      "list_num_m": "Number of list displayed on article pages included other contents",
+      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages",
+
+      "list_num_l": "Number of list displayed on 'Search' pages",
+      "list_num_desc_l": "Set number of list per page such as 'Search' pages",
+
+      "list_num_xl": "Number of list displayed on article pages",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages",
+
+
+
       "stale_notification": "Display notification on stale pages",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "show_all_reply_comments": "Show all reply comments",

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

@@ -107,8 +107,19 @@
       "tab_switch_desc2": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
       "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
       "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-      "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
-      "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
+
+      "list_num_s": "モーダルに表示されるリスト数",
+      "list_num_desc_s": "モーダルにおける <Pagelist> <Timeline> <Page History> <Share Link>での、1ページあたりの表示数を設定します。",
+
+      "list_num_m": "ユーザーページに表示されるリスト数",
+      "list_num_desc_m": "ユーザーページにおける <Bookmarks> <Recently Created>での、1ページあたりの表示数を設定します。",
+
+      "list_num_l": "検索ページに表示されるリスト数",
+      "list_num_desc_l": "<Search>での、1ページあたりの表示数を設定します。",
+
+      "list_num_xl": "Not FoundページやTrashページに表示されるリスト数",
+      "list_num_desc_xl": "記事エリアにおける<Not Found> <Trash>での、1ページあたりの表示数を設定します。",
+
       "stale_notification": "古いページに通知を表示する",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "show_all_reply_comments": "返信コメントを全て表示する",

+ 14 - 3
resource/locales/zh_CN/admin/admin.json

@@ -118,9 +118,20 @@
 			"tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
 			"tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
 			"attach_title_header": "自动创建新页面时添加h1节",
-			"attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
-			"recent_created__n_draft_num_desc": "显示最近创建的页数和草稿数",
-			"recently_created_n_draft_num_desc": "用户页上显示的最近创建的页和草稿数",
+      "attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
+
+      "list_num_s": "Number of list displayed on modals",
+      "list_num_desc_s": "Set number of list per page such as 'Pagelist', 'Timeline', 'Page History' and 'Share Link' pages",
+
+      "list_num_m": "Number of list displayed on article pages included other contents",
+      "list_num_desc_m": "Set number of list per page such as 'Bookmarks' and 'Recently created' pages",
+
+      "list_num_l": "Number of list displayed on 'Search' pages",
+      "list_num_desc_l": "Set number of list per page such as 'Search' pages",
+
+      "list_num_xl": "Number of list displayed on article pages",
+      "list_num_desc_xl": "Set number of list per page such as 'Not found' and 'Trash' pages",
+
 			"stale_notification": "在过期页上显示通知",
 			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
 			"show_all_reply_comments": "显示所有回复评论",

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

@@ -21,7 +21,7 @@ import NotFoundPage from './components/NotFoundPage';
 import NotFoundAlert from './components/Page/NotFoundAlert';
 import PageStatusAlert from './components/PageStatusAlert';
 import RecentCreated from './components/RecentCreated/RecentCreated';
-import MyDraftList from './components/MyDraftList/MyDraftList';
+import MyBookmarkList from './components/MyBookmarkList/MyBookmarkList';
 import SeenUserList from './components/User/SeenUserList';
 import LikerList from './components/User/LikerList';
 import TableOfContents from './components/TableOfContents';
@@ -99,7 +99,9 @@ if (pageContainer.state.pageId != null) {
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
 
-    'user-draft-list': <MyDraftList />,
+    'user-bookmark-list': <MyBookmarkList />,
+    'user-created-list': <RecentCreated />,
+    // 'user-draft-list': <MyDraftList />,
   });
 }
 if (pageContainer.state.creator != null) {

+ 30 - 38
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -1,10 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import {
-  Card, CardBody,
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
-} from 'reactstrap';
+import { Card, CardBody } from 'reactstrap';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
@@ -14,6 +11,7 @@ import AppContainer from '../../../services/AppContainer';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import CustomizeFunctionOption from './CustomizeFunctionOption';
+import PagingSizeUncontrolledDropdown from './PagingSizeUncontrolledDropdown';
 
 class CustomizeFunctionSetting extends React.Component {
 
@@ -21,17 +19,10 @@ class CustomizeFunctionSetting extends React.Component {
     super(props);
 
     this.state = {
-      isDropdownOpen: false,
     };
-
-    this.onToggleDropdown = this.onToggleDropdown.bind(this);
     this.onClickSubmit = this.onClickSubmit.bind(this);
   }
 
-  onToggleDropdown() {
-    this.setState({ isDropdownOpen: !this.state.isDropdownOpen });
-  }
-
   async onClickSubmit() {
     const { t, adminCustomizeContainer } = this.props;
 
@@ -74,7 +65,6 @@ class CustomizeFunctionSetting extends React.Component {
                 </CustomizeFunctionOption>
               </div>
             </div>
-
             <div className="form-group row">
               <div className="offset-md-3 col-md-6 text-left">
                 <CustomizeFunctionOption
@@ -90,32 +80,34 @@ class CustomizeFunctionSetting extends React.Component {
               </div>
             </div>
 
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <div className="my-0 w-100">
-                  <label>{t('admin:customize_setting.function_options.recent_created__n_draft_num_desc')}</label>
-                </div>
-                <Dropdown isOpen={this.state.isDropdownOpen} toggle={this.onToggleDropdown}>
-                  <DropdownToggle className="text-right col-6" caret>
-                    <span className="float-left">{adminCustomizeContainer.state.currentRecentCreatedLimit}</span>
-                  </DropdownToggle>
-                  <DropdownMenu className="dropdown-menu" role="menu">
-                    <DropdownItem key={10} role="presentation" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(10) }}>
-                      <a role="menuitem">10</a>
-                    </DropdownItem>
-                    <DropdownItem key={30} role="presentation" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(30) }}>
-                      <a role="menuitem">30</a>
-                    </DropdownItem>
-                    <DropdownItem key={50} role="presentation" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(50) }}>
-                      <a role="menuitem">50</a>
-                    </DropdownItem>
-                  </DropdownMenu>
-                </Dropdown>
-                <p className="form-text text-muted">
-                  {t('admin:customize_setting.function_options.recently_created_n_draft_num_desc')}
-                </p>
-              </div>
-            </div>
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_s')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_s')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationS}
+              dropdownItemSize={[10, 20, 50, 100]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationS}
+            />
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_m')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_m')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationM}
+              dropdownItemSize={[5, 10, 20, 50, 100]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationM}
+            />
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_l')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_l')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationL}
+              dropdownItemSize={[20, 50, 100, 200]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationL}
+            />
+            <PagingSizeUncontrolledDropdown
+              label={t('admin:customize_setting.function_options.list_num_xl')}
+              desc={t('admin:customize_setting.function_options.list_num_desc_xl')}
+              toggleLabel={adminCustomizeContainer.state.pageLimitationXL}
+              dropdownItemSize={[5, 10, 20, 50, 100]}
+              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationXL}
+            />
 
             <div className="form-group row">
               <div className="offset-md-3 col-md-6 text-left">

+ 58 - 0
src/client/js/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx

@@ -0,0 +1,58 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import {
+  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+
+const PagingSizeUncontrolledDropdown = (props) => {
+
+  function dropdownItemOnClickHandler(num) {
+    if (props.onChangeDropdownItem === null) {
+      return;
+    }
+    props.onChangeDropdownItem(num);
+  }
+
+  return (
+    <React.Fragment>
+      <div className="form-group row">
+        <div className="offset-md-3 col-md-6 text-left">
+          <div className="my-0 w-100">
+            <label>{props.label}</label>
+          </div>
+          <UncontrolledDropdown>
+            <DropdownToggle className="text-right col-6" caret>
+              <span className="float-left">{props.toggleLabel}</span>
+            </DropdownToggle>
+            <DropdownMenu className="dropdown-menu" role="menu">
+              {props.dropdownItemSize.map((num) => {
+                return (
+                  <DropdownItem key={num} role="presentation" onClick={() => dropdownItemOnClickHandler(num)}>
+                    <a role="menuitem">{num}</a>
+                  </DropdownItem>
+                );
+              })}
+            </DropdownMenu>
+          </UncontrolledDropdown>
+          <p className="form-text text-muted">
+            {props.desc}
+          </p>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+
+PagingSizeUncontrolledDropdown.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  label: PropTypes.string,
+  toggleLabel: PropTypes.number,
+  dropdownItemSize: PropTypes.array,
+  desc: PropTypes.string,
+  onChangeDropdownItem: PropTypes.func,
+};
+
+export default withTranslation()(PagingSizeUncontrolledDropdown);

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

@@ -13,6 +13,31 @@ import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurit
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
 import ShareLinkList from '../../ShareLink/ShareLinkList';
 
+
+const Pager = (props) => {
+  if (props.links.length === 0) {
+    return null;
+  }
+  return (
+    <PaginationWrapper
+      activePage={props.activePage}
+      changePage={props.handlePage}
+      totalItemsCount={props.totalLinks}
+      pagingLimit={props.limit}
+      align="right"
+      size="sm"
+    />
+  );
+};
+
+Pager.propTypes = {
+  links: PropTypes.array.isRequired,
+  activePage: PropTypes.number.isRequired,
+  handlePage: PropTypes.func.isRequired,
+  totalLinks: PropTypes.number.isRequired,
+  limit: PropTypes.number.isRequired,
+};
+
 class ShareLinkSetting extends React.Component {
 
   constructor() {
@@ -87,22 +112,6 @@ class ShareLinkSetting extends React.Component {
       shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
     } = adminGeneralSecurityContainer.state;
 
-    function pager() {
-      if (shareLinks.length === 0) {
-        return null;
-      }
-      return (
-        <PaginationWrapper
-          activePage={shareLinksActivePage}
-          changePage={this.getShareLinkList}
-          totalItemsCount={totalshareLinks}
-          pagingLimit={shareLinksPagingLimit}
-          align="right"
-          size="sm"
-        />
-      );
-    }
-
     return (
       <Fragment>
         <div className="mb-3">
@@ -116,7 +125,13 @@ class ShareLinkSetting extends React.Component {
           </button>
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
-        {pager}
+        <Pager
+          links={shareLinks}
+          activePage={shareLinksActivePage}
+          handlePage={this.getShareLinkList}
+          totalLinks={totalshareLinks}
+          limit={shareLinksPagingLimit}
+        />
 
         {(shareLinks.length !== 0) ? (
           <ShareLinkList

+ 109 - 0
src/client/js/components/MyBookmarkList/MyBookmarkList.jsx

@@ -0,0 +1,109 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import loggerFactory from '@alias/logger';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import { toastError } from '../../util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import Page from '../PageList/Page';
+
+const logger = loggerFactory('growi:MyBookmarkList');
+class MyBookmarkList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      pages: [],
+      activePage: 1,
+      totalPages: 0,
+      pagingLimit: null,
+    };
+
+    this.handlePage = this.handlePage.bind(this);
+  }
+
+  componentWillMount() {
+    this.getMyBookmarkList(1);
+  }
+
+  async handlePage(selectPageNumber) {
+    await this.getMyBookmarkList(selectPageNumber);
+  }
+
+  async getMyBookmarkList(selectPageNumber) {
+    const { appContainer } = this.props;
+    const userId = appContainer.currentUserId;
+    const page = selectPageNumber;
+
+    try {
+      const { data } = await this.props.appContainer.apiv3.get(`/bookmarks/${userId}`, { page });
+      if (data.paginationResult == null) {
+        throw new Error('data must conclude \'paginateResult\' property.');
+      }
+      const {
+        docs: pages, totalDocs: totalPages, limit: pagingLimit, page: activePage,
+      } = data.paginationResult;
+      this.setState({
+        pages,
+        totalPages,
+        pagingLimit,
+        activePage,
+      });
+    }
+    catch (error) {
+      logger.error('failed to fetch data', error);
+      toastError(error, 'Error occurred in bookmark page list');
+    }
+  }
+
+  /**
+   * generate Elements of Page
+   *
+   * @param {any} pages Array of pages Model Obj
+   *
+   */
+  generatePageList(pages) {
+    return pages.map(page => (
+      <li key={`my-bookmarks:${page._id}`}>
+        <Page page={page.page} />
+      </li>
+    ));
+  }
+
+
+  render() {
+    return (
+      <div className="page-list-container-create">
+        <ul className="page-list-ul page-list-ul-flat mb-3">
+          {this.generatePageList(this.state.pages)}
+        </ul>
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalPages}
+          pagingLimit={this.state.pagingLimit}
+        />
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const MyBookmarkListWrapper = withUnstatedContainers(MyBookmarkList, [AppContainer, PageContainer]);
+
+MyBookmarkList.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+export default MyBookmarkListWrapper;

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

@@ -22,6 +22,7 @@ class MyDraftList extends React.Component {
       currentDrafts: [],
       activePage: 1,
       totalDrafts: 0,
+      // [TODO: rename pageLimitationM to pageLimitationL]
       pagingLimit: Infinity,
     };
 
@@ -67,9 +68,8 @@ class MyDraftList extends React.Component {
   }
 
   getCurrentDrafts(selectPageNumber) {
-    const { appContainer } = this.props;
-
-    const limit = appContainer.getConfig().recentCreatedLimit;
+    // TODO implement temporarily paging number only this component (this paging size is pageLimitationL).
+    const limit = this.state.pagingLimit;
 
     const totalDrafts = this.state.drafts.length;
     const activePage = selectPageNumber;

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

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

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

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

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

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

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

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

+ 4 - 6
src/client/js/components/PageList.jsx

@@ -19,23 +19,21 @@ const PageList = (props) => {
 
   const [activePage, setActivePage] = useState(1);
   const [totalPages, setTotalPages] = useState(0);
-  const [limit, setLimit] = useState(appContainer.getConfig().recentCreatedLimit);
-  const [offset, setOffset] = useState(0);
+  const [limit, setLimit] = useState(null);
 
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
-    setOffset((selectedPageNumber - 1) * limit);
   }
 
   const updatePageList = useCallback(async() => {
-    const res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+    const page = activePage;
+    const res = await appContainer.apiv3Get('/pages/list', { path, page });
 
     setPages(res.data.pages);
     setIsLoading(true);
     setTotalPages(res.data.totalCount);
     setLimit(res.data.limit);
-    setOffset(res.data.offset);
-  }, [appContainer, path, limit, offset]);
+  }, [appContainer, path, activePage]);
 
   useEffect(() => {
     updatePageList();

+ 6 - 7
src/client/js/components/PageTimeline.jsx

@@ -16,11 +16,10 @@ class PageTimeline extends React.Component {
   constructor(props) {
     super(props);
 
-    const { appContainer } = this.props;
     this.state = {
       activePage: 1,
       totalPageItems: 0,
-      limit: appContainer.getConfig().recentCreatedLimit,
+      limit: null,
 
       // TODO: remove after when timeline is implemented with React and inject data with props
       pages: this.props.pages,
@@ -33,17 +32,17 @@ class PageTimeline extends React.Component {
   async handlePage(selectedPage) {
     const { appContainer, pageContainer } = this.props;
     const { path } = pageContainer.state;
-    const { limit } = this.state;
-    const offset = (selectedPage - 1) * limit;
-    const activePage = selectedPage;
+    const page = selectedPage;
 
-    const res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+    const res = await appContainer.apiv3Get('/pages/list', { path, page });
     const totalPageItems = res.data.totalCount;
     const pages = res.data.pages;
+    const pagingLimit = res.data.limit;
     this.setState({
-      activePage,
+      activePage: selectedPage,
       totalPageItems,
       pages,
+      limit: pagingLimit,
     });
   }
 

+ 4 - 3
src/client/js/components/PaginationWrapper.jsx

@@ -14,10 +14,10 @@ class PaginationWrapper extends React.Component {
     super(props);
 
     this.state = {
-      totalItemsCount: 0,
       activePage: 1,
+      totalItemsCount: 0,
       paginationNumbers: {},
-      limit: Infinity,
+      limit: this.props.pagingLimit || Infinity,
     };
 
     this.calculatePagination = this.calculatePagination.bind(this);
@@ -189,13 +189,14 @@ PaginationWrapper.propTypes = {
   activePage: PropTypes.number.isRequired,
   changePage: PropTypes.func.isRequired,
   totalItemsCount: PropTypes.number.isRequired,
-  pagingLimit: PropTypes.number.isRequired,
+  pagingLimit: PropTypes.number,
   align: PropTypes.string,
   size: PropTypes.string,
 };
 PaginationWrapper.defaultProps = {
   align: 'left',
   size: 'md',
+  pagingLimit: PropTypes.number,
 };
 
 export default withTranslation()(PaginationWrappered);

+ 7 - 8
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -17,7 +17,7 @@ class RecentCreated extends React.Component {
       pages: [],
       activePage: 1,
       totalPages: 0,
-      pagingLimit: Infinity,
+      pagingLimit: null,
     };
 
     this.handlePage = this.handlePage.bind(this);
@@ -32,19 +32,18 @@ class RecentCreated extends React.Component {
     await this.getRecentCreatedList(selectedPage);
   }
 
-  async getRecentCreatedList(selectPageNumber) {
+  async getRecentCreatedList(selectedPage) {
     const { appContainer, userId } = this.props;
-
-    const limit = appContainer.getConfig().recentCreatedLimit;
-    const offset = (selectPageNumber - 1) * limit;
+    const page = selectedPage;
+    // const userId = appContainer.currentUserId;
 
     // pagesList get and pagination calculate
-    const res = await appContainer.apiv3Get(`/users/${userId}/recent`, { offset, limit });
-    const { totalCount, pages } = res.data;
+    const res = await appContainer.apiv3Get(`/users/${userId}/recent`, { page });
+    const { totalCount, pages, limit } = res.data;
 
     this.setState({
       pages,
-      activePage: selectPageNumber,
+      activePage: selectedPage,
       totalPages: totalCount,
       pagingLimit: limit,
     });

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

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

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

@@ -0,0 +1,21 @@
+import React from 'react';
+
+const SlackLogo = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 448 448"
+    height="20"
+    width="20"
+  >
+    <path
+      d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,
+      0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,
+      47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,
+      0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,
+      47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,
+      0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"
+    />
+  </svg>
+);
+
+export default SlackLogo;

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

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

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

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

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

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

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

@@ -521,4 +521,8 @@ export default class PageContainer extends Container {
 
   }
 
+  /* TODO GW-325 */
+  retrieveMyBookmarkList() {
+  }
+
 }

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

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

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

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

+ 0 - 20
src/client/styles/scss/_on-edit.scss

@@ -106,26 +106,6 @@ body.on-edit {
   .grw-editor-navbar-bottom {
     height: $grw-editor-navbar-bottom-height;
 
-    .grw-slack-notification {
-      .input-group-addon {
-        padding: 2px 8px;
-        line-height: 1em;
-        img,
-        input {
-          vertical-align: middle;
-        }
-      }
-      .form-control {
-        width: 80px;
-        @include media-breakpoint-up(sm) {
-          width: 130px;
-        }
-        @include media-breakpoint-up(md) {
-          width: 180px;
-        }
-      }
-    }
-
     .grw-grant-selector {
       @include media-breakpoint-down(sm) {
         .btn .label {

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

@@ -125,3 +125,4 @@ $pre-color: dummyinvalildcolor; // disable pre color specification with invalid
 $custom-checkbox-indicator-border-radius: 0px;
 $custom-control-indicator-focus-box-shadow: none;
 $custom-control-indicator-size: 1.2rem;
+

+ 54 - 0
src/client/styles/scss/atoms/_custom_control.scss

@@ -32,3 +32,57 @@ label.custom-control-label {
     }
   }
 }
+
+//lg
+.custom-switch.custom-switch-lg {
+  $custom-control-indicator-size-lg: $custom-control-indicator-size * 1.5;
+  $custom-switch-width-lg: $custom-control-indicator-size-lg * 1.75;
+  $custom-control-gutter-lg: $custom-control-gutter * 1.5;
+  $custom-control-indicator-size-lg: $custom-control-indicator-size * 1.5;
+  $custom-switch-indicator-size-lg: subtract($custom-control-indicator-size-lg, $custom-control-indicator-border-width * 4);
+
+  padding-left: $custom-switch-width-lg + $custom-control-gutter-lg;
+
+  line-height: $custom-control-indicator-size-lg;
+  .custom-control-label {
+    &::before {
+      top: ($font-size-base * $line-height-base - $custom-control-indicator-size-lg) / 2;
+
+      left: -($custom-switch-width-lg + $custom-control-gutter-lg);
+      width: $custom-switch-width-lg;
+      height: $custom-control-indicator-size-lg;
+      border-radius: $custom-control-indicator-size-lg/2;
+    }
+
+    &::after {
+      top: add(($font-size-base * $line-height-base - $custom-control-indicator-size-lg) / 2, $custom-control-indicator-border-width * 2);
+      left: add(-($custom-switch-width-lg + $custom-control-gutter-lg), $custom-control-indicator-border-width * 2);
+      width: $custom-switch-indicator-size-lg;
+      height: $custom-switch-indicator-size-lg;
+      border-radius: $custom-control-indicator-size-lg/2;
+    }
+  }
+
+  .custom-control-input:checked ~ .custom-control-label {
+    &::after {
+      transform: translateX($custom-switch-width-lg - $custom-control-indicator-size-lg);
+    }
+  }
+}
+
+.custom-switch.custom-switch-slack {
+  .custom-control-label {
+    &::before {
+      background-color: $gray-200;
+      border-color: transparent;
+    }
+    &::after {
+      background-size: 15px;
+    }
+  }
+  .input-group-addon {
+    input {
+      vertical-align: middle;
+    }
+  }
+}

+ 42 - 0
src/client/styles/scss/molecules/slack-notification.scss

@@ -0,0 +1,42 @@
+.grw-slack-notification {
+  $input-height-slack: $custom-control-indicator-size * 1.5;
+  border-color: $gray-200;
+
+  border-style: solid;
+  border-width: 1px;
+  border-radius: $input-height-slack/2 2px 2px $input-height-slack/2;
+
+  .form-control {
+    height: $input-height-slack;
+    border: transparent;
+    @include media-breakpoint-up(sm) {
+      width: 130px;
+    }
+    @include media-breakpoint-up(md) {
+      width: 180px;
+    }
+  }
+  // height settings for slack button's responsive design
+  // in the input and form-control element
+  .grw-form-control-slack-notification {
+    height: $input-height-slack;
+  }
+  .grw-input-group-slack-notification {
+    height: $input-height-slack;
+    label {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      margin-bottom: 0;
+    }
+  }
+
+  .custom-control-label {
+    &::before {
+      border: transparent;
+    }
+  }
+}
+// TODO デザインの使用が確定して実装、本タスクのスコープ外
+// .grw-slack-notification-xd {
+// }

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

@@ -23,6 +23,7 @@
 
 // molecules
 @import 'molecules/copy-dropdown';
+@import 'molecules/slack-notification';
 
 // growi component
 @import 'admin';

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

@@ -281,6 +281,48 @@ body.on-edit {
   }
 }
 
+.grw-slack-notification {
+  background-color: transparent;
+  $color-slack: #4b144c;
+
+  .custom-control-label {
+    &::before {
+      background-color: $secondary;
+      border-color: transparent;
+    }
+    &::after {
+      background-color: darken($color-slack, 5%);
+      background-image: url(/images/icons/slack/slack-logo-dark-off.svg);
+    }
+  }
+
+  .custom-control-input:checked ~ .custom-control-label {
+    &::before {
+      background-color: lighten($color-slack, 10%);
+    }
+    &::after {
+      background-color: darken($color-slack, 5%);
+      background-image: url(/images/icons/slack/slack-logo-dark-on.svg);
+    }
+  }
+}
+
+.grw-slack-logo svg {
+  fill: #dd80de;
+}
+
+.grw-btn-slack {
+  background-color: black;
+  &:focus,
+  &:hover {
+    background-color: black;
+  }
+}
+
+.grw-btn-slack-triangle {
+  color: $secondary;
+}
+
 /*
  * GROWI HandsontableModal
  */

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

@@ -36,6 +36,10 @@ $table-hover-bg: $bgcolor-table-hover;
   background-color: $bgcolor-global;
 }
 
+.form-control::placeholder {
+  color: darken($bgcolor-global, 20%);
+}
+
 .form-control[disabled],
 .form-control[readonly] {
   color: lighten($color-global, 10%);
@@ -203,6 +207,47 @@ $table-hover-bg: $bgcolor-table-hover;
   }
 }
 
+.grw-slack-notification {
+  background-color: white;
+  $color-slack: #4b144c;
+
+  .custom-control-label {
+    &::before {
+      background-color: $gray-200;
+      border-color: transparent;
+    }
+    &::after {
+      background-color: white;
+      background-image: url(/images/icons/slack/slack-logo-off.svg);
+    }
+  }
+  .custom-control-input:checked ~ .custom-control-label {
+    &::before {
+      background-color: lighten($color-slack, 60%);
+    }
+    &::after {
+      background-image: url(/images/icons/slack/slack-logo-on.svg);
+    }
+  }
+}
+
+.grw-slack-logo svg {
+  fill: #af30b0;
+}
+
+.grw-btn-slack {
+  background-color: white;
+
+  &:hover,
+  &:focus {
+    background-color: white;
+  }
+}
+
+.grw-btn-slack-triangle {
+  color: $secondary;
+}
+
 /*
  * GROWI HandsontableModal
  */

+ 1 - 1
src/server/crowi/index.js

@@ -278,7 +278,7 @@ Crowi.prototype.setupS2sMessagingService = async function() {
 Crowi.prototype.setupSocketIoService = async function() {
   const SocketIoService = require('../service/socket-io');
   if (this.socketIoService == null) {
-    this.socketIoService = new SocketIoService();
+    this.socketIoService = new SocketIoService(this);
   }
 };
 

+ 7 - 1
src/server/middlewares/admin-required.js

@@ -2,7 +2,7 @@ const loggerFactory = require('@alias/logger');
 
 const logger = loggerFactory('growi:middleware:admin-required');
 
-module.exports = (crowi) => {
+module.exports = (crowi, fallback = null) => {
 
   return async(req, res, next) => {
     if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
@@ -13,11 +13,17 @@ module.exports = (crowi) => {
 
       logger.warn('This user is not admin.');
 
+      if (fallback != null) {
+        return fallback(req, res);
+      }
       return res.redirect('/');
     }
 
     logger.warn('This user has not logged in.');
 
+    if (fallback != null) {
+      return fallback(req, res);
+    }
     return res.redirect('/login');
   };
 

+ 8 - 1
src/server/middlewares/login-required.js

@@ -6,8 +6,9 @@ const logger = loggerFactory('growi:middleware:login-required');
  * require login handler
  *
  * @param {boolean} isGuestAllowed whethere guest user is allowed (default false)
+ * @param {function} fallback fallback function which will be triggered when the check cannot be passed
  */
-module.exports = (crowi, isGuestAllowed = false) => {
+module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
 
   return function(req, res, next) {
 
@@ -45,9 +46,15 @@ module.exports = (crowi, isGuestAllowed = false) => {
     // is api path
     const path = req.path || '';
     if (path.match(/^\/_api\/.+$/)) {
+      if (fallback != null) {
+        return fallback(req, res);
+      }
       return res.sendStatus(403);
     }
 
+    if (fallback != null) {
+      return fallback(req, res);
+    }
     req.session.redirectTo = req.originalUrl;
     return res.redirect('/login');
   };

+ 2 - 0
src/server/models/bookmark.js

@@ -2,6 +2,7 @@
 
 const debug = require('debug')('growi:models:bookmark');
 const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
@@ -18,6 +19,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
+  bookmarkSchema.plugin(mongoosePaginate);
   bookmarkSchema.plugin(uniqueValidator);
 
   bookmarkSchema.statics.countByPageId = async function(pageId) {

+ 4 - 2
src/server/models/config.js

@@ -111,7 +111,10 @@ module.exports = function(crowi) {
       'customize:isEnabledTimeline' : true,
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isEnabledAttachTitleHeader' : false,
-      'customize:showRecentCreatedNumber' : 10,
+      'customize:showPageLimitationS' : 20,
+      'customize:showPageLimitationM' : 10,
+      'customize:showPageLimitationL' : 50,
+      'customize:showPageLimitationXL' : 20,
       'customize:isEnabledStaleNotification': false,
       'customize:isAllReplyShown': false,
 
@@ -218,7 +221,6 @@ module.exports = function(crowi) {
         MATHJAX: env.MATHJAX || null,
         NO_CDN: env.NO_CDN || null,
       },
-      recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       isEnabledStaleNotification: crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAclEnabled: crowi.aclService.isAclEnabled(),
       isSearchServiceConfigured: crowi.searchService.isConfigured,

+ 7 - 5
src/server/routes/apiv3/attachment.js

@@ -23,12 +23,10 @@ module.exports = (crowi) => {
   const Attachment = crowi.model('Attachment');
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
-
   const validator = {
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('limit').isInt({ min: 1 }),
-      query('offset').isInt({ min: 0 }),
+      query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
   };
   /**
@@ -50,8 +48,10 @@ module.exports = (crowi) => {
    *              type: string
    */
   router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
-    const offset = +req.query.offset || 0;
-    const limit = +req.query.limit || 30;
+
+    const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
+    const page = req.query.page;
+    const offset = (page - 1) * limit;
 
     try {
       const pageId = req.query.pageId;
@@ -62,6 +62,8 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
       }
 
+      // directly get paging-size from db. not to delivery from client side.
+
       const paginateResult = await Attachment.paginate(
         { page: pageId },
         {

+ 84 - 1
src/server/routes/apiv3/bookmarks.js

@@ -59,7 +59,7 @@ module.exports = (crowi) => {
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
-  const { Page, Bookmark } = crowi.models;
+  const { Page, Bookmark, User } = crowi.models;
 
   const validator = {
     bookmarks: [
@@ -108,6 +108,89 @@ module.exports = (crowi) => {
     }
   });
 
+  // select page from bookmark where userid = userid
+  /**
+   * @swagger
+   *
+   *    /bookmarks/{userId}:
+   *      get:
+   *        tags: [Bookmarks]
+   *        summary: /bookmarks/{userId}
+   *        description: Get my bookmarked status
+   *        operationId: getMyBookmarkedStatus
+   *        parameters:
+   *          - name: userId
+   *            in: path
+   *            required: true
+   *            description: user id
+   *            schema:
+   *              type: string
+   *          - name: page
+   *            in: query
+   *            description: selected page number
+   *            schema:
+   *              type: number
+   *          - name: limit
+   *            in: query
+   *            description: page item limit
+   *            schema:
+   *              type: number
+   *          - name: offset
+   *            in: query
+   *            description: page item offset
+   *            schema:
+   *              type: number
+   *        responses:
+   *          200:
+   *            description: Succeeded to get my bookmarked status.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/Bookmark'
+   */
+  validator.myBookmarkList = [
+    query('page').isInt({ min: 1 }),
+    query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
+  ];
+
+  router.get('/:userId', accessTokenParser, loginRequired, validator.myBookmarkList, apiV3FormValidator, async(req, res) => {
+    const { userId } = req.params;
+    const page = req.query.page;
+    const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationM') || 30;
+
+    if (userId == null) {
+      return res.apiv3Err('User id is not found or forbidden', 400);
+    }
+    if (limit == null) {
+      return res.apiv3Err('Could not catch page limit', 400);
+    }
+    try {
+      const paginationResult = await Bookmark.paginate(
+        {
+          user: { $in: userId },
+        },
+        {
+          populate: {
+            path: 'page',
+            model: 'Page',
+            populate: {
+              path: 'lastUpdateUser',
+              model: 'User',
+              select: User.USER_PUBLIC_FIELDS,
+            },
+          },
+          page,
+          limit,
+        },
+      );
+      return res.apiv3({ paginationResult });
+    }
+    catch (err) {
+      logger.error('get-bookmark-failed', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
 
   /**
    * @swagger

+ 19 - 5
src/server/routes/apiv3/customize-setting.js

@@ -37,7 +37,9 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: boolean
  *          isEnabledAttachTitleHeader:
  *            type: boolean
- *          recentCreatedLimit:
+ *          pageLimitationS:
+ *            type: number
+ *          pageLimitationM:
  *            type: number
  *          isEnabledStaleNotification:
  *            type: boolean
@@ -99,7 +101,10 @@ module.exports = (crowi) => {
       body('isEnabledTimeline').isBoolean(),
       body('isSavedStatesOfTabChanges').isBoolean(),
       body('isEnabledAttachTitleHeader').isBoolean(),
-      body('recentCreatedLimit').isInt().isInt({ min: 1, max: 1000 }),
+      body('pageLimitationS').isInt().isInt({ min: 1, max: 1000 }),
+      body('pageLimitationM').isInt().isInt({ min: 1, max: 1000 }),
+      body('pageLimitationL').isInt().isInt({ min: 1, max: 1000 }),
+      body('pageLimitationXL').isInt().isInt({ min: 1, max: 1000 }),
       body('isEnabledStaleNotification').isBoolean(),
       body('isAllReplyShown').isBoolean(),
     ],
@@ -151,7 +156,10 @@ module.exports = (crowi) => {
       isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
       isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
       isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
-      recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+      pageLimitationS: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 20,
+      pageLimitationM: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationM') || 10,
+      pageLimitationL: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL') || 50,
+      pageLimitationXL: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL') || 20,
       isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
@@ -273,7 +281,10 @@ module.exports = (crowi) => {
       'customize:isEnabledTimeline': req.body.isEnabledTimeline,
       'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
       'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
-      'customize:showRecentCreatedNumber': req.body.recentCreatedLimit,
+      'customize:showPageLimitationS': req.body.pageLimitationS,
+      'customize:showPageLimitationM': req.body.pageLimitationM,
+      'customize:showPageLimitationL': req.body.pageLimitationL,
+      'customize:showPageLimitationXL': req.body.pageLimitationXL,
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
       'customize:isAllReplyShown': req.body.isAllReplyShown,
     };
@@ -284,7 +295,10 @@ module.exports = (crowi) => {
         isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
         isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
         isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
-        recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+        pageLimitationS: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS'),
+        pageLimitationM: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationM'),
+        pageLimitationL: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
+        pageLimitationXL: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
         isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
         isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       };

+ 15 - 7
src/server/routes/apiv3/pages.js

@@ -1,12 +1,10 @@
 const loggerFactory = require('@alias/logger');
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-
 const express = require('express');
 
-
 const router = express.Router();
-
+const { query } = require('express-validator');
 
 /**
  * @swagger
@@ -18,9 +16,13 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
 
   const Page = crowi.model('Page');
 
+  const validator = {};
+
   /**
    * @swagger
    *
@@ -84,11 +86,17 @@ module.exports = (crowi) => {
     }
   });
 
-  router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
-    const { path } = req.query;
-    const limit = +req.query.limit || 30;
-    const offset = +req.query.offset || 0;
+  validator.displayList = [
+    query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
+  ];
+
+  router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
     const { isTrashPage } = require('@commons/util/path-utils');
+    
+    const { path } = req.query;
+    const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
+    const page = req.query.page || 1;
+    const offset = (page - 1) * limit;
 
     let includeTrashed = false;
 

+ 6 - 5
src/server/routes/apiv3/revisions.js

@@ -9,8 +9,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
 
-const PAGE_ITEMS = 30;
-
 /**
  * @swagger
  *  tags:
@@ -71,7 +69,9 @@ module.exports = (crowi) => {
   const validator = {
     retrieveRevisions: [
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('selectedPage').isInt({ min: 0 }).withMessage('selectedPage must be int'),
+      query('page').isInt({ min: 0 }).withMessage('page must be int'),
+      query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
+
     ],
     retrieveRevisionById: [
       query('pageId').isMongoId().withMessage('pageId is required'),
@@ -99,9 +99,10 @@ module.exports = (crowi) => {
    */
   router.get('/list', certifySharedPage, accessTokenParser, loginRequired, validator.retrieveRevisions, apiV3FormValidator, async(req, res) => {
     const pageId = req.query.pageId;
+    const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
     const { isSharedPage } = req;
 
-    const selectedPage = parseInt(req.query.selectedPage) || 1;
+    const selectedPage = parseInt(req.query.page) || 1;
 
     // check whether accessible
     if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
@@ -115,7 +116,7 @@ module.exports = (crowi) => {
         { path: page.path },
         {
           page: selectedPage,
-          limit: PAGE_ITEMS,
+          limit,
           sort: { createdAt: -1 },
           populate: {
             path: 'author',

+ 8 - 3
src/server/routes/apiv3/users.js

@@ -105,6 +105,10 @@ module.exports = (crowi) => {
     query('page').isInt({ min: 1 }),
   ];
 
+  validator.recentCreatedByUser = [
+    query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
+  ];
+
   /**
    * @swagger
    *
@@ -224,7 +228,7 @@ module.exports = (crowi) => {
    *                    paginateResult:
    *                      $ref: '#/components/schemas/PaginateResult'
    */
-  router.get('/:id/recent', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/:id/recent', accessTokenParser, loginRequired, validator.recentCreatedByUser, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
 
     let user;
@@ -242,8 +246,9 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('find-user-is-not-found'));
     }
 
-    const limit = parseInt(req.query.limit) || 50;
-    const offset = parseInt(req.query.offset) || 0;
+    const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationM') || 30;
+    const page = req.query.page;
+    const offset = (page - 1) * limit;
     const queryOptions = { offset, limit };
 
     try {

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

@@ -155,6 +155,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  S2CMSG_PUBSUB_CONNECTIONS_LIMIT: {
+    ns:      'crowi',
+    key:     's2cMessagingPubsub:connectionsLimit',
+    type:    TYPES.NUMBER,
+    default: 5000,
+  },
+  S2CMSG_PUBSUB_CONNECTIONS_LIMIT_FOR_ADMIN: {
+    ns:      'crowi',
+    key:     's2cMessagingPubsub:connectionsLimitForAdmin',
+    type:    TYPES.NUMBER,
+    default: 100,
+  },
+  S2CMSG_PUBSUB_CONNECTIONS_LIMIT_FOR_GUEST: {
+    ns:      'crowi',
+    key:     's2cMessagingPubsub:connectionsLimitForGuest',
+    type:    TYPES.NUMBER,
+    default: 2000,
+  },
   MAX_FILE_SIZE: {
     ns:      'crowi',
     key:     'app:maxFileSize',

+ 2 - 1
src/server/service/search-delegator/elasticsearch.js

@@ -310,7 +310,8 @@ class ElasticsearchDelegator {
     let document = {
       path: page.path,
       body: page.revision.body,
-      username: page.creator?.username,
+      // username: page.creator?.username, // available Node.js v14 and above
+      username: page.creator != null ? page.creator.username : null,
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
       like_count: page.liker.length || 0,

+ 160 - 0
src/server/service/socket-io.js

@@ -1,10 +1,23 @@
 const socketIo = require('socket.io');
+const expressSession = require('express-session');
+const passport = require('passport');
+const socketioSession = require('@kobalab/socket.io-session');
+
+const logger = require('@alias/logger')('growi:service:socket-io');
+
 
 /**
  * Serve socket.io for server-to-client messaging
  */
 class SocketIoService {
 
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+
+    this.guestClients = new Set();
+  }
+
   get isInitialized() {
     return (this.io != null);
   }
@@ -16,6 +29,15 @@ class SocketIoService {
 
     // create namespace for admin
     this.adminNamespace = this.io.of('/admin');
+
+    // setup middlewares
+    // !!CAUTION!! -- ORDER IS IMPORTANT
+    this.setupSessionMiddleware();
+    this.setupLoginRequiredMiddleware();
+    this.setupAdminRequiredMiddleware();
+    this.setupCheckConnectionLimitsMiddleware();
+
+    this.setupStoreGuestIdEventHandler();
   }
 
   getDefaultSocket() {
@@ -29,9 +51,147 @@ class SocketIoService {
     if (this.io == null) {
       throw new Error('Http server has not attached yet.');
     }
+
     return this.adminNamespace;
   }
 
+  /**
+   * use passport session
+   * @see https://qiita.com/kobalab/items/083e507fb01159fe9774
+   */
+  setupSessionMiddleware() {
+    const sessionMiddleware = socketioSession(expressSession(this.crowi.sessionConfig), passport);
+    this.io.use(sessionMiddleware.express_session);
+    this.io.use(sessionMiddleware.passport_initialize);
+    this.io.use(sessionMiddleware.passport_session);
+  }
+
+  /**
+   * use loginRequired middleware
+   */
+  setupLoginRequiredMiddleware() {
+    const loginRequired = require('../middlewares/login-required')(this.crowi, true, (req, res, next) => {
+      next(new Error('Login is required to connect.'));
+    });
+
+    // convert Connect/Express middleware to Socket.io middleware
+    this.io.use((socket, next) => {
+      loginRequired(socket.request, {}, next);
+    });
+  }
+
+  /**
+   * use adminRequired middleware
+   */
+  setupAdminRequiredMiddleware() {
+    const adminRequired = require('../middlewares/admin-required')(this.crowi, (req, res, next) => {
+      next(new Error('Admin priviledge is required to connect.'));
+    });
+
+    // convert Connect/Express middleware to Socket.io middleware
+    this.getAdminSocket().use((socket, next) => {
+      adminRequired(socket.request, {}, next);
+    });
+  }
+
+  /**
+   * use checkConnectionLimits middleware
+   */
+  setupCheckConnectionLimitsMiddleware() {
+    this.getAdminSocket().use(this.checkConnectionLimitsForAdmin.bind(this));
+    this.getDefaultSocket().use(this.checkConnectionLimitsForGuest.bind(this));
+    this.getDefaultSocket().use(this.checkConnectionLimits.bind(this));
+  }
+
+  setupStoreGuestIdEventHandler() {
+    this.io.on('connection', (socket) => {
+      if (socket.request.user == null) {
+        this.guestClients.add(socket.id);
+
+        socket.on('disconnect', () => {
+          this.guestClients.delete(socket.id);
+        });
+      }
+    });
+  }
+
+  async getClients(namespace) {
+    return new Promise((resolve, reject) => {
+      namespace.clients((error, clients) => {
+        if (error) {
+          reject(error);
+        }
+        resolve(clients);
+      });
+    });
+  }
+
+  async checkConnectionLimitsForAdmin(socket, next) {
+    const namespaceName = socket.nsp.name;
+
+    if (namespaceName === '/admin') {
+      const clients = await this.getClients(this.getAdminSocket());
+      const clientsCount = clients.length;
+
+      logger.debug('Current count of clients for \'/admin\':', clientsCount);
+
+      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForAdmin');
+      if (limit <= clientsCount) {
+        const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
+        logger.warn(msg);
+        next(new Error(msg));
+        return;
+      }
+    }
+
+    next();
+  }
+
+  async checkConnectionLimitsForGuest(socket, next) {
+
+    if (socket.request.user == null) {
+      const clientsCount = this.guestClients.size;
+
+      logger.debug('Current count of clients for guests:', clientsCount);
+
+      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForGuest');
+      if (limit <= clientsCount) {
+        const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
+        logger.warn(msg);
+        next(new Error(msg));
+        return;
+      }
+    }
+
+    next();
+  }
+
+  /**
+   * @see https://socket.io/docs/server-api/#socket-client
+   */
+  async checkConnectionLimits(socket, next) {
+    // exclude admin
+    const namespaceName = socket.nsp.name;
+    if (namespaceName === '/admin') {
+      next();
+    }
+
+    const clients = await this.getClients(this.getDefaultSocket());
+    const clientsCount = clients.length;
+
+    logger.debug('Current count of clients for \'/\':', clientsCount);
+
+    const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimit');
+    if (limit <= clientsCount) {
+      const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
+      logger.warn(msg);
+      next(new Error(msg));
+      return;
+    }
+
+    next();
+  }
+
 }
 
 module.exports = SocketIoService;

+ 3 - 8
src/server/views/widget/user_page_content.html

@@ -12,13 +12,8 @@
         <span class="d-none d-sm-inline">Recently Created</span>
       </a>
     </li>
+
     {% if user._id.toString() == pageUser._id.toString() %}
-    <li class="nav-item">
-      <a class="nav-link" href="#user-draft-list" role="tab" data-toggle="tab">
-        <i class="icon-docs"></i>
-        <span class="d-none d-sm-inline">My Drafts</span>
-      </a>
-    </li>
     <li class="nav-item">
       <a class="nav-link" href="/me" role="tab">
         <i class="icon-wrench"></i>
@@ -35,9 +30,9 @@
         {{t('No bookmarks yet')}}.
       {% else %}
         <div class="page-list-container">
-          {% include 'page_list.html' with { pages: bookmarkList, pagePropertyName: 'page' } %}
+          {# {% include 'page_list.html' with { pages: bookmarkList, pagePropertyName: 'page' } %} #}
         </div>
-      {% endif %}
+        {# {% endif %} #}
     </div>
 
     <div class="tab-pane user-created-list page-list" id="user-created-list">

+ 63 - 0
src/test/middlewares/login-required.test.js

@@ -4,13 +4,17 @@ const { getInstance } = require('../setup-crowi');
 
 describe('loginRequired', () => {
   let crowi;
+  const fallbackMock = jest.fn().mockReturnValue('fallback');
+
   let loginRequiredStrictly;
   let loginRequired;
+  let loginRequiredWithFallback;
 
   beforeEach(async(done) => {
     crowi = await getInstance();
     loginRequiredStrictly = require('@server/middlewares/login-required')(crowi);
     loginRequired = require('@server/middlewares/login-required')(crowi, true);
+    loginRequiredWithFallback = require('@server/middlewares/login-required')(crowi, false, fallbackMock);
     done();
   });
 
@@ -33,6 +37,7 @@ describe('loginRequired', () => {
       const result = loginRequired(req, res, next);
 
       expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(result).toBe('next');
@@ -46,6 +51,7 @@ describe('loginRequired', () => {
       const result = loginRequired(req, res, next);
 
       expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
@@ -63,6 +69,7 @@ describe('loginRequired', () => {
       const result = loginRequired(req, res, next);
 
       expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(result).toBe('next');
@@ -100,6 +107,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(res.sendStatus).toHaveBeenCalledTimes(1);
       expect(res.sendStatus).toHaveBeenCalledWith(403);
@@ -113,6 +121,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
@@ -131,6 +140,7 @@ describe('loginRequired', () => {
       const result = loginRequiredStrictly(req, res, next);
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalledTimes(1);
@@ -154,6 +164,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith(expectedPath);
@@ -175,6 +186,7 @@ describe('loginRequired', () => {
 
       expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
       expect(next).not.toHaveBeenCalled();
+      expect(fallbackMock).not.toHaveBeenCalled();
       expect(res.sendStatus).not.toHaveBeenCalled();
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
@@ -184,4 +196,55 @@ describe('loginRequired', () => {
 
   });
 
+  describe('specified fallback', () => {
+    // setup req/res/next
+    const req = {
+      originalUrl: 'original url 1',
+      session: null,
+    };
+    const res = {
+      redirect: jest.fn().mockReturnValue('redirect'),
+      sendStatus: jest.fn().mockReturnValue('sendStatus'),
+    };
+    const next = jest.fn().mockReturnValue('next');
+
+    let isGuestAllowedToReadSpy;
+
+    beforeEach(async(done) => {
+      // reset session object
+      req.session = {};
+      // spy for AclService.isGuestAllowedToRead
+      isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead');
+      done();
+    });
+
+    test('invoke fallback when \'req.path\' starts with \'_api\'', () => {
+      req.path = '/_api/someapi';
+
+      const result = loginRequiredWithFallback(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(fallbackMock).toHaveBeenCalledTimes(1);
+      expect(fallbackMock).toHaveBeenCalledWith(req, res);
+      expect(result).toBe('fallback');
+    });
+
+    test('invoke fallback when the user does not loggedin', () => {
+      req.path = '/path/that/requires/loggedin';
+
+      const result = loginRequiredWithFallback(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(res.sendStatus).not.toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(fallbackMock).toHaveBeenCalledTimes(1);
+      expect(fallbackMock).toHaveBeenCalledWith(req, res);
+      expect(result).toBe('fallback');
+    });
+
+  });
 });

+ 5 - 0
yarn.lock

@@ -1673,6 +1673,11 @@
   resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-1.0.1.tgz#1ba7886c0474cc31707acd195f7b9073406b743d"
   integrity sha512-gQZ0a3JcrCi1g+00D9CIbo2uPc6lnykqAsVaCbew8jsrdyF0f0cBngYgFKcTxW2vliT5I3K4lwD4DhM6hXeOjg==
 
+"@kobalab/socket.io-session@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@kobalab/socket.io-session/-/socket.io-session-1.0.3.tgz#87d55896bb48f57c57f26f0235bf53345a0a4615"
+  integrity sha512-pen2rqNuZUsR453EVM9owqDIbelFKa5gizyNM9hscphKrdPIYissNa9efddYSVBH24q7pknxS5kxbfSw/YYOMg==
+
 "@lykmapipo/common@>=0.34.2", "@lykmapipo/common@>=0.34.3":
   version "0.34.3"
   resolved "https://registry.yarnpkg.com/@lykmapipo/common/-/common-0.34.3.tgz#eb74fa4af14f2f1e59ddd42491f05ab69f96bd71"