Преглед изворни кода

Merge pull request #2813 from weseek/feat/transplant-tabs-to-modal-for-master-merge

Feat/transplant tabs to modal for master merge
Yuki Takei пре 5 година
родитељ
комит
b201cfc994
60 измењених фајлова са 1251 додато и 726 уклоњено
  1. 4 0
      resource/locales/en_US/translation.json
  2. 5 1
      resource/locales/ja_JP/translation.json
  3. 7 3
      resource/locales/zh_CN/translation.json
  4. 0 19
      src/client/js/app.jsx
  5. 0 17
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  6. 9 8
      src/client/js/components/Admin/ManageExternalAccount.jsx
  7. 34 29
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  8. 2 1
      src/client/js/components/Admin/UserManagement.jsx
  9. 31 0
      src/client/js/components/FootstampIcon.jsx
  10. 27 0
      src/client/js/components/Icons/AttachmentIcon.jsx
  11. 0 0
      src/client/js/components/Icons/GrowiLogo.jsx
  12. 17 0
      src/client/js/components/Icons/PageListIcon.jsx
  13. 22 0
      src/client/js/components/Icons/PresentationIcon.jsx
  14. 21 0
      src/client/js/components/Icons/RecentChangesIcon.jsx
  15. 35 0
      src/client/js/components/Icons/ShareLinkIcon.jsx
  16. 19 0
      src/client/js/components/Icons/TimeLineIcon.jsx
  17. 1 1
      src/client/js/components/Navbar/GrowiNavbar.jsx
  18. 57 0
      src/client/js/components/Page/PageManagement.jsx
  19. 0 159
      src/client/js/components/Page/PageShareManagement.jsx
  20. 160 0
      src/client/js/components/PageAccessoriesModal.jsx
  21. 55 19
      src/client/js/components/PageAttachment.jsx
  22. 8 10
      src/client/js/components/PageHistory.jsx
  23. 86 0
      src/client/js/components/PageList.jsx
  24. 31 0
      src/client/js/components/PagePresentationModal.jsx
  25. 61 72
      src/client/js/components/PageTimeline.jsx
  26. 19 1
      src/client/js/components/PaginationWrapper.jsx
  27. 29 43
      src/client/js/components/ShareLink/ShareLink.jsx
  28. 4 4
      src/client/js/components/ShareLink/ShareLinkForm.jsx
  29. 3 3
      src/client/js/components/ShareLink/ShareLinkList.jsx
  30. 4 3
      src/client/js/components/TableOfContents.jsx
  31. 95 0
      src/client/js/components/TopOfTableContents.jsx
  32. 29 19
      src/client/js/components/User/SeenUserList.jsx
  33. 0 23
      src/client/js/legacy/crowi.js
  34. 54 0
      src/client/js/services/PageAccessoriesContainer.js
  35. 12 16
      src/client/js/services/PageContainer.js
  36. 0 24
      src/client/styles/scss/_layout.scss
  37. 6 4
      src/client/styles/scss/_layout_growi.scss
  38. 13 0
      src/client/styles/scss/_page-presentation.scss
  39. 0 45
      src/client/styles/scss/_page.scss
  40. 40 0
      src/client/styles/scss/_page_accessaries_modal.scss
  41. 49 0
      src/client/styles/scss/_toc.scss
  42. 3 0
      src/client/styles/scss/style-app.scss
  43. 42 2
      src/client/styles/scss/theme/_apply-colors.scss
  44. 38 0
      src/migrations/20200903080025-remove-timeline-type.js.js
  45. 3 0
      src/server/models/attachment.js
  46. 91 0
      src/server/routes/apiv3/attachment.js
  47. 1 0
      src/server/routes/apiv3/index.js
  48. 18 0
      src/server/routes/apiv3/pages.js
  49. 0 58
      src/server/routes/attachment.js
  50. 0 1
      src/server/routes/index.js
  51. 3 8
      src/server/views/layout-growi/page.html
  52. 0 5
      src/server/views/layout-growi/page_list.html
  53. 0 12
      src/server/views/layout-growi/user_page.html
  54. 0 12
      src/server/views/layout-growi/widget/liker-and-seenusers.html
  55. 0 5
      src/server/views/layout-kibela/page.html
  56. 2 0
      src/server/views/widget/page_content.html
  57. 0 39
      src/server/views/widget/page_list_and_timeline.html
  58. 0 38
      src/server/views/widget/page_list_and_timeline_kibela.html
  59. 0 21
      src/server/views/widget/page_tabs.html
  60. 1 1
      src/server/views/widget/user_page_content.html

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

@@ -46,6 +46,8 @@
   "List View": "List",
   "List View": "List",
   "Timeline View": "Timeline",
   "Timeline View": "Timeline",
   "History": "History",
   "History": "History",
+  "attachment_data": "Attachment Data",
+  "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "Presentation",
   "Presentation Mode": "Presentation",
   "Not available for guest": "Not available for guest",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "Create Archive Page",
   "Create Archive Page": "Create Archive Page",
@@ -117,6 +119,7 @@
   "Specified users only": "Specified users only",
   "Specified users only": "Specified users only",
   "Only me": "Only me",
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "Only inside the group": "Only inside the group",
+  "page_list": "Page List",
   "page_list_and_search_results": "Page list / Search results",
   "page_list_and_search_results": "Page list / Search results",
   "scope_of_page_disclosure": "Scope of page disclosure",
   "scope_of_page_disclosure": "Scope of page disclosure",
   "set_point": "Set point",
   "set_point": "Set point",
@@ -136,6 +139,7 @@
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "Disassociate": "Disassociate",
+  "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "Recent Created",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Recent Changes": "Recent Changes",
   "personal_dropdown": {
   "personal_dropdown": {

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

@@ -45,8 +45,10 @@
   "Example": "例",
   "Example": "例",
   "Taro Yamada": "山田 太郎",
   "Taro Yamada": "山田 太郎",
   "List View": "リスト表示",
   "List View": "リスト表示",
-  "Timeline View": "タイムライン表示",
+  "Timeline View": "タイムライン",
   "History": "更新履歴",
   "History": "更新履歴",
+  "attachment_data": "添付データ",
+  "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "プレゼンテーション",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Create Archive Page": "アーカイブページの作成",
   "Create Archive Page": "アーカイブページの作成",
@@ -117,6 +119,7 @@
   "Specified users": "特定ユーザーのみ",
   "Specified users": "特定ユーザーのみ",
   "Only me": "自分のみ",
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "Only inside the group": "特定グループのみ",
+  "page_list": "ページリスト",
   "page_list_and_search_results": "ページリスト・検索結果",
   "page_list_and_search_results": "ページリスト・検索結果",
   "scope_of_page_disclosure": "ページの公開範囲",
   "scope_of_page_disclosure": "ページの公開範囲",
   "set_point": "設定値",
   "set_point": "設定値",
@@ -139,6 +142,7 @@
   "Color mode": "カラーモード",
   "Color mode": "カラーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
+  "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "最新の作成",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Recent Changes": "最新の変更",
   "personal_dropdown": {
   "personal_dropdown": {

+ 7 - 3
resource/locales/zh_CN/translation.json

@@ -47,7 +47,9 @@
 	"Taro Yamada": "John Doe",
 	"Taro Yamada": "John Doe",
 	"List View": "列表",
 	"List View": "列表",
 	"Timeline View": "时间线",
 	"Timeline View": "时间线",
-	"History": "历史",
+  "History": "历史",
+  "attachment_data": "Attachment Data",
+  "No_attachments_yet": "暂无附件",
 	"Presentation Mode": "演示文稿",
 	"Presentation Mode": "演示文稿",
   "Not available for guest": "Not available for guest",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "创建归档页",
   "Create Archive Page": "创建归档页",
@@ -124,7 +126,8 @@
 	"Anyone with the link": "任何人",
 	"Anyone with the link": "任何人",
 	"Specified users only": "仅指定用户",
 	"Specified users only": "仅指定用户",
 	"Only me": "只有我",
 	"Only me": "只有我",
-	"Only inside the group": "仅组内",
+  "Only inside the group": "仅组内",
+  "page_list": "Page List",
 	"page_list_and_search_results": "页面列表/搜索结果",
 	"page_list_and_search_results": "页面列表/搜索结果",
 	"scope_of_page_disclosure": "页面公开范围",
 	"scope_of_page_disclosure": "页面公开范围",
 	"set_point": "设定值",
 	"set_point": "设定值",
@@ -143,7 +146,8 @@
 	"List Drafts": "草稿",
 	"List Drafts": "草稿",
 	"Deleted Pages": "已删除页",
 	"Deleted Pages": "已删除页",
 	"Sign out": "退出",
 	"Sign out": "退出",
-	"Disassociate": "解除关联",
+  "Disassociate": "解除关联",
+  "No bookmarks yet": "暂无书签",
 	"Recent Created": "最新创建",
 	"Recent Created": "最新创建",
 	"Recent Changes": "最新修改",
 	"Recent Changes": "最新修改",
 	"form_validation": {
 	"form_validation": {

+ 0 - 19
src/client/js/app.jsx

@@ -14,14 +14,11 @@ import EditorNavbarBottom from './components/PageEditor/EditorNavbarBottom';
 import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
 import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import Page from './components/Page';
-import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
 import PageComments from './components/PageComments';
 import PageTimeline from './components/PageTimeline';
 import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
 import PageManagement from './components/Page/PageManagement';
-import PageShareManagement from './components/Page/PageShareManagement';
 import TrashPageAlert from './components/Page/TrashPageAlert';
 import TrashPageAlert from './components/Page/TrashPageAlert';
-import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import PageStatusAlert from './components/PageStatusAlert';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import MyDraftList from './components/MyDraftList/MyDraftList';
@@ -91,10 +88,7 @@ if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
     'page-comments-list': <PageComments />,
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-attachment': <PageAttachment />,
     'page-management': <PageManagement />,
     'page-management': <PageManagement />,
-    'page-share-management': <PageShareManagement />,
-
     'revision-toc': <TableOfContents />,
     'revision-toc': <TableOfContents />,
     'seen-user-list': <SeenUserList />,
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
     'liker-list': <LikerList />,
@@ -141,18 +135,5 @@ Object.keys(componentMappings).forEach((key) => {
   }
   }
 });
 });
 
 
-// うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
-$('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <ErrorBoundary>
-        <Provider inject={injectableContainers}>
-          <PageHistory />
-        </Provider>
-      </ErrorBoundary>
-    </I18nextProvider>, document.getElementById('revision-history'),
-  );
-});
-
 // initialize scrollpos-styler
 // initialize scrollpos-styler
 ScrollPosStyler.init();
 ScrollPosStyler.init();

+ 0 - 17
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -59,23 +59,6 @@ class CustomizeFunctionSetting extends React.Component {
             </Card>
             </Card>
 
 
 
 
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isEnabledTimeline"
-                  label={t('admin:customize_setting.function_options.timeline')}
-                  isChecked={adminCustomizeContainer.state.isEnabledTimeline}
-                  onChecked={() => { adminCustomizeContainer.switchEnableTimeline() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.timeline_desc1')}<br />
-                    {t('admin:customize_setting.function_options.timeline_desc2')}<br />
-                    {t('admin:customize_setting.function_options.timeline_desc3')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-
             <div className="form-group row">
             <div className="form-group row">
               <div className="offset-md-3 col-md-6 text-left">
               <div className="offset-md-3 col-md-6 text-left">
                 <CustomizeFunctionOption
                 <CustomizeFunctionOption

+ 9 - 8
src/client/js/components/Admin/ManageExternalAccount.jsx

@@ -36,14 +36,15 @@ class ManageExternalAccount extends React.Component {
     const { t, adminExternalAccountsContainer } = this.props;
     const { t, adminExternalAccountsContainer } = this.props;
 
 
     const pager = (
     const pager = (
-      <div className="pull-right">
-        <PaginationWrapper
-          activePage={adminExternalAccountsContainer.state.activePage}
-          changePage={this.handleExternalAccountPage}
-          totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
-          pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
-        />
-      </div>
+
+      <PaginationWrapper
+        activePage={adminExternalAccountsContainer.state.activePage}
+        changePage={this.handleExternalAccountPage}
+        totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
+        pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
+        align="right"
+      />
+
     );
     );
     return (
     return (
       <Fragment>
       <Fragment>

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

@@ -11,7 +11,7 @@ import AppContainer from '../../../services/AppContainer';
 import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
-import ShareLinkList from '../../ShareLinkList';
+import ShareLinkList from '../../ShareLink/ShareLinkList';
 
 
 class ShareLinkSetting extends React.Component {
 class ShareLinkSetting extends React.Component {
 
 
@@ -66,6 +66,7 @@ class ShareLinkSetting extends React.Component {
 
 
   async deleteLinkById(shareLinkId) {
   async deleteLinkById(shareLinkId) {
     const { t, appContainer, adminGeneralSecurityContainer } = this.props;
     const { t, appContainer, adminGeneralSecurityContainer } = this.props;
+    const { shareLinksActivePage } = adminGeneralSecurityContainer.state;
 
 
     try {
     try {
       const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
       const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
@@ -76,53 +77,57 @@ class ShareLinkSetting extends React.Component {
       toastError(err);
       toastError(err);
     }
     }
 
 
-    this.getShareLinkList(adminGeneralSecurityContainer.state.shareLinksActivePage);
+    this.getShareLinkList(shareLinksActivePage);
   }
   }
 
 
 
 
   render() {
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
     const { t, adminGeneralSecurityContainer } = this.props;
-
-    const pager = (
-      <div className="pull-right my-3">
+    const {
+      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+    } = adminGeneralSecurityContainer.state;
+
+    function pager() {
+      if (shareLinks.length === 0) {
+        return null;
+      }
+      return (
         <PaginationWrapper
         <PaginationWrapper
-          activePage={adminGeneralSecurityContainer.state.shareLinksActivePage}
+          activePage={shareLinksActivePage}
           changePage={this.getShareLinkList}
           changePage={this.getShareLinkList}
-          totalItemsCount={adminGeneralSecurityContainer.state.totalshareLinks}
-          pagingLimit={adminGeneralSecurityContainer.state.shareLinksPagingLimit}
+          totalItemsCount={totalshareLinks}
+          pagingLimit={shareLinksPagingLimit}
+          align="right"
         />
         />
-      </div>
-    );
+      );
+    }
 
 
-    const deleteAllButton = (
-      adminGeneralSecurityContainer.state.shareLinks.length > 0
-        ? (
+    return (
+      <Fragment>
+        <div className="mb-3">
           <button
           <button
             className="pull-right btn btn-danger"
             className="pull-right btn btn-danger"
+            disabled={shareLinks.length === 0}
             type="button"
             type="button"
             onClick={this.showDeleteConfirmModal}
             onClick={this.showDeleteConfirmModal}
           >
           >
             {t('share_links.delete_all_share_links')}
             {t('share_links.delete_all_share_links')}
           </button>
           </button>
-        )
-        : (
-          <p className="pull-right mr-2">{t('share_links.No_share_links')}</p>
-        )
-    );
-
-    return (
-      <Fragment>
-        <div className="mb-3">
-          {deleteAllButton}
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
         </div>
-
         {pager}
         {pager}
-        <ShareLinkList
-          shareLinks={adminGeneralSecurityContainer.state.shareLinks}
-          onClickDeleteButton={this.deleteLinkById}
-          isAdmin
-        />
+
+        {(shareLinks.length !== 0) ? (
+          <ShareLinkList
+            shareLinks={shareLinks}
+            onClickDeleteButton={this.deleteLinkById}
+            isAdmin
+          />
+          )
+          : (<p className="text-center">{t('share_links.No_share_links')}</p>
+          )
+        }
+
 
 
         <DeleteAllShareLinksModal
         <DeleteAllShareLinksModal
           isOpen={this.state.isDeleteConfirmModalShown}
           isOpen={this.state.isDeleteConfirmModalShown}

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

@@ -114,12 +114,13 @@ class UserManagement extends React.Component {
     const { t, adminUsersContainer } = this.props;
     const { t, adminUsersContainer } = this.props;
 
 
     const pager = (
     const pager = (
-      <div className="pull-right my-3">
+      <div className="my-3">
         <PaginationWrapper
         <PaginationWrapper
           activePage={adminUsersContainer.state.activePage}
           activePage={adminUsersContainer.state.activePage}
           changePage={this.handlePage}
           changePage={this.handlePage}
           totalItemsCount={adminUsersContainer.state.totalUsers}
           totalItemsCount={adminUsersContainer.state.totalUsers}
           pagingLimit={adminUsersContainer.state.pagingLimit}
           pagingLimit={adminUsersContainer.state.pagingLimit}
+          align="right"
         />
         />
       </div>
       </div>
     );
     );

+ 31 - 0
src/client/js/components/FootstampIcon.jsx

@@ -0,0 +1,31 @@
+import React from 'react';
+
+const FootstampIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="16"
+    height="16"
+    viewBox="0 0 16 16"
+  >
+    <path d="M7.34,8,3.31,9a1.83,1.83,0,0,1-1.24-.08A1.28,1.28,0,0,1,1.34,8a3.24,3.24,0,0,1,.2-1.82A6.06,6.06,0,0,1,2.6,4.35h0a2.56,
+    2.56,0,0,1,3.34-.77A5.65,5.65,0,0,1,7.69,4.73a3.23,3.23,0,0,1,1,1.53A1.29,1.29,0,0,1,8.42,7.4,1.86,1.86,0,0,1,7.34,8Zm-3-3.82a2.17,2.17,0,0,
+    0-1.05.74h0a4.75,4.75,0,0,0-.89,1.52,2.37,2.37,0,0,0-.17,1.3.38.38,0,0,0,.23.31,1,1,0,0,0,.65,
+      0l4-.94a1,1,0,0,0,.58-.3.39.39,0,0,0,.07-.38,2.32,2.32,0,0,0-.73-1.08,4.7,4.7,0,0,0-1.47-1A2.07,2.07,0,0,0,4.33,4.2Z"
+    />
+    <path d="M7.26,1.39a.57.57,0,0,0-.18,0,.81.81,0,0,0-.61,1l.09.38a.81.81,0,0,0,.79.63l.19,0a.82.82,0,0,0,.6-1L8.05,2a.81.81,0,0,0-.79-.63Z" />
+    <path d="M.81,2.9a.55.55,0,0,0-.18,0h0a.81.81,0,0,0-.61,1l.09.38A.81.81,0,0,0,.9,4.9l.18,0h0a.82.82,0,0,0,.61-1L1.6,3.52A.8.8,0,0,0,.81,2.9Z" />
+    <path d="M2.29.61a.57.57,0,0,0-.18,0,.81.81,0,0,0-.61,1l.16.7a.81.81,0,0,0,.79.63l.19,0h0a.8.8,0,0,0,.6-1l-.16-.71A.82.82,0,0,0,2.29.61Z" />
+    <path d="M4.93,0,4.75,0a.82.82,0,0,0-.61,1l.16.7a.82.82,0,0,0,.79.63l.19,0h0a.82.82,0,0,0,.61-1L5.72.63A.81.81,0,0,0,4.93,0Z" />
+    <path d="M13.22,16l-4.1-.54A1.88,1.88,0,0,1,8,14.94a1.34,1.34,0,0,1-.36-1.12,3.19,3.19,0,0,1,.83-1.62,5.73,5.73,0,0,1,1.62-1.32h0a2.57,2.57,
+    0,0,1,3.4.44A5.82,5.82,0,0,1,14.7,13a3.21,3.21,0,0,1,.38,1.78,1.28,1.28,0,0,1-.63,1A1.94,1.94,0,0,1,13.22,16Zm-1.48-4.64a2.12,2.12,0,0,
+    0-1.24.33h0a5.07,5.07,0,0,0-1.37,1.11,2.41,2.41,0,0,0-.62,1.16.43.43,0,0,0,.11.37,1.08,1.08,0,0,0,.61.24l4.11.53A1,1,0,0,0,14,15a.41.41,0,0,
+    0,.2-.33,2.47,2.47,0,0,0-.3-1.28,5,5,0,0,0-1-1.42A2.12,2.12,0,0,0,11.74,11.34Z"
+    />
+    <path d="M15.19,9.69a.82.82,0,0,0-.81.71l-.05.39a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.7l.05-.39a.8.8,0,0,0-.7-.91Z" />
+    <path d="M8.62,8.84a.82.82,0,0,0-.81.7l0,.39a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.71l.06-.39a.82.82,0,0,0-.7-.91Z" />
+    <path d="M10.8,7.22a.81.81,0,0,0-.8.7l-.09.72a.81.81,0,0,0,.7.91h.1a.83.83,0,0,0,.81-.71l.09-.72a.82.82,0,0,0-.7-.91Z" />
+    <path d="M13.49,7.57a.81.81,0,0,0-.8.71l-.1.71a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.71l.1-.71a.81.81,0,0,0-.7-.91Z" />
+  </svg>
+);
+
+export default FootstampIcon;

+ 27 - 0
src/client/js/components/Icons/AttachmentIcon.jsx

@@ -0,0 +1,27 @@
+import React from 'react';
+
+const Attachment = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <g className="cls-1">
+      <path
+        d="M2.9,13a2,2,0,0,1-1.44-.63,2.28,2.28,0,0,1,0-3.23l7-7.38a2.48,2.48,0,0,1,1.22-.7,2.61,
+        2.61,0,0,1,1.41.09A3.46,3.46,0,0,1,12.37,2a3.94,3.94,0,0,1,.36.45A2.61,2.61,0,0,1,13,3a3.41,3.41,
+        0,0,1,.16.57,3.06,3.06,0,0,1-.82,2.75L7.07,11.86a.35.35,0,0,1-.26.13.4.4,0,0,1-.28-.1.47.47,0,0,
+        1-.12-.27.39.39,0,0,1,.11-.29l5.26-5.59a2.28,2.28,0,0,0,.65-1.62,2.07,2.07,0,0,0-.62-1.58A2.62,2.62,
+        0,0,0,11,1.93a2,2,0,0,0-1-.13,1.63,1.63,0,0,0-1,.5L2,9.67a1.52,1.52,0,0,0,0,2.16,1.28,1.28,0,0,0,
+        .44.3,1,1,0,0,0,.51.08,1.43,1.43,0,0,0,1-.49L9.49,5.84l.12-.13.11-.15a1.24,1.24,0,0,0,.1-.2,1.94,
+        1.94,0,0,0,0-.2.6.6,0,0,0,0-.22.66.66,0,0,0-.14-.2.57.57,0,0,0-.45-.22,1,1,0,0,0-.52.3L4.56,
+        9.25a.42.42,0,0,1-.17.1.34.34,0,0,1-.2,0A.4.4,0,0,1,4,9.26.34.34,0,0,1,3.89,9,.41.41,0,0,1,4,8.72L8.16,
+        4.28a1.7,1.7,0,0,1,1-.53,1.32,1.32,0,0,1,1.06.43,1.23,1.23,0,0,1,.4,1.05,1.8,1.8,0,0,1-.58,1.14L4.52,
+        12.26A2.3,2.3,0,0,1,3,13H2.9Z"
+      />
+    </g>
+  </svg>
+);
+
+export default Attachment;

+ 0 - 0
src/client/js/components/GrowiLogo.jsx → src/client/js/components/Icons/GrowiLogo.jsx


+ 17 - 0
src/client/js/components/Icons/PageListIcon.jsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+const PageList = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path d="M12.63,2.72H1.37a.54.54,0,0,1,0-1.08H12.63a.54.54,0,0,1,0,1.08Z" />
+    <path d="M11.82,5.94H1.37a.55.55,0,0,1,0-1.09H11.82a.55.55,0,1,1,0,1.09Z" />
+    <path d="M9.41,9.15h-8a.54.54,0,0,1,0-1.08h8a.54.54,0,0,1,0,1.08Z" />
+    <path d="M10.84,12.36H1.37a.54.54,0,1,1,0-1.08h9.47a.54.54,0,1,1,0,1.08Z" />
+  </svg>
+);
+
+export default PageList;

+ 22 - 0
src/client/js/components/Icons/PresentationIcon.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+
+const PresentationIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="12.25"
+    height="14"
+    viewBox="0 0 12.25 14"
+  >
+    <path
+      d="M44.261,0H32.909a.448.448,0,0,0-.449.448V7.635a.449.449,0,0,0,.9,0V.9H43.812V7.635a.449.449,0,0,0,.9,0V.448A.448.448,0,0,0,44.261,0Z"
+      transform="translate(-32.46)"
+    />
+    <path
+      d="M90.959,287.182H82.315a.448.448,0,1,0,0,.9h3.873v1.115l-3.207,3.381a.449.449,0,0,0,.652.616l2.555-2.694v2.013a.449.449,0,0,0,.9,0V
+        290.5l2.555,2.694a.449.449,0,0,0,.652-.616l-3.208-3.382v-1.114h3.873a.448.448,0,1,0,0-.9Z"
+      transform="translate(-80.512 -279.329)"
+    />
+  </svg>
+);
+
+export default PresentationIcon;

+ 21 - 0
src/client/js/components/Icons/RecentChangesIcon.jsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+const RecentChanges = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path
+      d="M7.94.94A6.13,6.13,0,0,0,1.89,7v.1L.67,5.89a.38.38,0,0,0-.55,0,.39.39,0,0,0,0,.56L2.36,8.69,4.6,6.45a.4.4,0,0,0,0-.56.39.39,0,0,0-.56,
+      0L2.68,7.25V7A5.33,5.33,0,0,1,7.94,1.73,5.33,5.33,0,0,1,13.21,7a5.34,5.34,0,0,1-5.27,5.27H7.86A5,5,0,0,1,4,10.38a.4.4,0,0,0-.55-.07.4.4,0,
+      0,0-.07.56,5.83,5.83,0,0,0,4.52,2.19H8A6.13,6.13,0,0,0,14,7,6.13,6.13,0,0,0,7.94.94Z"
+    />
+    <path
+      d="M7.94,2.83a.4.4,0,0,0-.39.4V7.37L10,8.92a.37.37,0,0,0,.21.06.4.4,0,0,0,.21-.73L8.34,6.93V3.23A.4.4,0,0,0,7.94,2.83Z"
+    />
+  </svg>
+);
+
+export default RecentChanges;

+ 35 - 0
src/client/js/components/Icons/ShareLinkIcon.jsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+const ShareLink = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+    <g transform="translate(-142 -502)">
+      <rect width="20" height="20" transform="translate(142 502)" fill="none" />
+      <g transform="translate(16 286.938)">
+        <path
+          d="M-1.813-3.563a2.711,2.711,0,0,0-1.274.308,2.8,2.8,0,0,0-.976.835L-11.48-6.2a2.676,2.676,
+          0,0,0,.105-.738,2.555,2.555,0,0,0-.044-.466,3.34,3.34,0,0,0-.114-.448l7.453-3.621a2.71,2.71,
+          0,0,0,.984.853,2.764,2.764,0,0,0,1.283.308,2.708,2.708,0,0,0,1.986-.826A2.708,2.708,0,0,
+          0,1-13.125a2.751,2.751,0,0,0-.378-1.406A2.793,2.793,0,0,0-.406-15.56a2.751,2.751,0,0,
+          0-1.406-.378,2.751,2.751,0,0,0-1.406.378,2.793,2.793,0,0,0-1.028,1.028,2.751,2.751,0,0,0-.378,
+          1.406v.105a.64.64,0,0,0,.009.105.641.641,0,0,1,.009.105A.641.641,0,0,0-4.6-12.7a.694.694,0,0,0,
+          .026.105.332.332,0,0,1,.018.105l-7.559,3.674a2.735,2.735,0,0,0-.923-.686,2.727,2.727,0,0,
+          0-1.151-.246,2.708,2.708,0,0,0-1.986.826A2.708,2.708,0,0,0-17-6.937a2.708,2.708,0,0,0,
+          .826,1.986,2.708,2.708,0,0,0,1.986.826A2.666,2.666,0,0,0-11.99-5.2l7.453,3.8a1.388,1.388,0,0,
+          0-.053.211q-.018.105-.026.22t-.009.22A2.751,2.751,0,0,0-4.247.656,2.792,2.792,0,0,0-3.219,
+          1.685a2.751,2.751,0,0,0,1.406.378A2.708,2.708,0,0,0,.174,1.236,2.708,2.708,0,0,0,1-.75,2.708,
+          2.708,0,0,0,.174-2.736,2.708,2.708,0,0,0-1.813-3.563Zm-1.2-10.758a1.627,1.627,0,0,1,1.2-.492,
+          1.627,1.627,0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2,1.627,1.627,0,0,1-.492,1.2,1.627,1.627,
+          0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,1.627,0,0,1-.492-1.2A1.627,1.627,0,0,
+          1-3.008-14.32Zm-9.984,8.578a1.627,1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,
+          1.627,0,0,1-.492-1.2,1.627,1.627,0,0,1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,
+          0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2A1.627,1.627,0,0,1-12.992-5.742ZM-.617.445a1.627,
+          1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492A1.627,1.627,0,0,1-3.5-.75a1.627,1.627,0,0,
+          1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,0,0,1,1.2.492A1.627,1.627,0,0,1-.125-.75,1.627,1.627,0,0,1-.617.445Z"
+          transform="translate(144 232)"
+        />
+      </g>
+    </g>
+  </svg>
+);
+
+export default ShareLink;

+ 19 - 0
src/client/js/components/Icons/TimeLineIcon.jsx

@@ -0,0 +1,19 @@
+import React from 'react';
+
+const TimeLine = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path
+      d="M13.6,4.6a1.2,1.2,0,0,1-1.2,1.2,1,1,0,0,1-.3,0L10,7.89a1.1,1.1,0,0,1,0,.31,1.2,1.2,0,1,1-2.4,0,1.1,1.1,0,0,1,
+      0-.31L6.11,6.36a1.3,1.3,0,0,1-.62,0L2.75,9.1a1,1,0,0,1,0,.3A1.2,1.2,0,1,1,1.6,8.2a1,1,0,0,1,.3,0L4.64,
+      5.51a1.1,1.1,0,0,1,0-.31A1.2,1.2,0,0,1,7,5.2a1.1,1.1,0,0,1,0,.31L8.49,7a1.3,1.3,0,0,1,.62,0L11.25,4.9a1,
+      1,0,0,1-.05-.3,1.2,1.2,0,1,1,2.4,0Z"
+    />
+  </svg>
+);
+
+export default TimeLine;

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

@@ -7,7 +7,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '../../services/NavigationContainer';
 import NavigationContainer from '../../services/NavigationContainer';
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
 
 
-import GrowiLogo from '../GrowiLogo';
+import GrowiLogo from '../Icons/GrowiLogo';
 
 
 import PersonalDropdown from './PersonalDropdown';
 import PersonalDropdown from './PersonalDropdown';
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';

+ 57 - 0
src/client/js/components/Page/PageManagement.jsx

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
+import urljoin from 'url-join';
 
 
 import { isTopPage } from '@commons/util/path-utils';
 import { isTopPage } from '@commons/util/path-utils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -11,6 +12,8 @@ import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
+import PagePresentationModal from '../PagePresentationModal';
+import PresentationIcon from '../Icons/PresentationIcon';
 
 
 
 
 const PageManagement = (props) => {
 const PageManagement = (props) => {
@@ -24,6 +27,7 @@ const PageManagement = (props) => {
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
   const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
+  const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
 
 
   function openPageRenameModalHandler() {
   function openPageRenameModalHandler() {
     setIsPageRenameModalShown(true);
     setIsPageRenameModalShown(true);
@@ -57,6 +61,44 @@ const PageManagement = (props) => {
     setIsPageDeleteModalShown(false);
     setIsPageDeleteModalShown(false);
   }
   }
 
 
+  function openPagePresentationModalHandler() {
+    setIsPagePresentationModalShown(true);
+  }
+
+  function closePagePresentationModalHandler() {
+    setIsPagePresentationModalShown(false);
+  }
+
+
+  // TODO GW-2746 bulk export pages
+  // async function getArchivePageData() {
+  //   try {
+  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
+  //     setTotalPages(res.data.dummy);
+  //   }
+  //   catch (err) {
+  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
+  //   }
+  // }
+
+  async function exportPageHandler(format) {
+    const { pageId, revisionId } = pageContainer.state;
+    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+    url.searchParams.append('format', format);
+    url.searchParams.append('revisionId', revisionId);
+    window.location.href = url.href;
+  }
+
+  // TODO GW-2746 create api to bulk export pages
+  // function openArchiveModalHandler() {
+  //   setIsArchiveCreateModalShown(true);
+  //   getArchivePageData();
+  // }
+
+  // TODO GW-2746 create api to bulk export pages
+  // function closeArchiveCreateModalHandler() {
+  //   setIsArchiveCreateModalShown(false);
+  // }
 
 
   function renderDropdownItemForNotTopPage() {
   function renderDropdownItemForNotTopPage() {
     return (
     return (
@@ -67,6 +109,16 @@ const PageManagement = (props) => {
         <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
         <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
         </button>
         </button>
+        <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
+          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
+        </button>
+        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
+        </button>
+        {/* TODO GW-2746 create api to bulk export pages */}
+        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
+          <i className="icon-fw"></i>{t('Create Archive Page')}
+        </button> */}
         <div className="dropdown-divider"></div>
         <div className="dropdown-divider"></div>
       </>
       </>
     );
     );
@@ -109,6 +161,11 @@ const PageManagement = (props) => {
           path={path}
           path={path}
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}
         />
         />
+        <PagePresentationModal
+          isOpen={isPagePresentationModalShown}
+          onClose={closePagePresentationModalHandler}
+          href="?presentation=1"
+        />
       </>
       </>
     );
     );
   }
   }

+ 0 - 159
src/client/js/components/Page/PageShareManagement.jsx

@@ -1,159 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import urljoin from 'url-join';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
-import OutsideShareLinkModal from '../OutsideShareLinkModal';
-
-// TODO GW-2746 bulk export pages
-// import ArchiveCreateModal from '../ArchiveCreateModal';
-
-const PageShareManagement = (props) => {
-  const { t, appContainer, pageContainer } = props;
-
-  // TODO GW-2746 bulk export pages
-  // eslint-disable-next-line no-unused-vars
-  const { path, pageId } = pageContainer.state;
-  const { currentUser } = appContainer;
-
-  const [isOutsideShareLinkModalShown, setIsOutsideShareLinkModalShown] = useState(false);
-
-  // TODO GW-2746 bulk export pages
-  // const [isArchiveCreateModalShown, setIsArchiveCreateModalShown] = useState(false);
-  // const [totalPages, setTotalPages] = useState(null);
-  // const [errorMessage, setErrorMessage] = useState(null);
-
-  function openOutsideShareLinkModalHandler() {
-    setIsOutsideShareLinkModalShown(true);
-  }
-
-  function closeOutsideShareLinkModalHandler() {
-    setIsOutsideShareLinkModalShown(false);
-  }
-
-  // TODO GW-2746 bulk export pages
-  // async function getArchivePageData() {
-  //   try {
-  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
-  //     setTotalPages(res.data.dummy);
-  //   }
-  //   catch (err) {
-  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
-  //   }
-  // }
-
-  async function exportPageHandler(format) {
-    const { pageId, revisionId } = pageContainer.state;
-    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
-    url.searchParams.append('format', format);
-    url.searchParams.append('revisionId', revisionId);
-    window.location.href = url.href;
-  }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function openArchiveModalHandler() {
-  //   setIsArchiveCreateModalShown(true);
-  //   getArchivePageData();
-  // }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function closeArchiveCreateModalHandler() {
-  //   setIsArchiveCreateModalShown(false);
-  // }
-
-
-  function renderModals() {
-    if (currentUser == null) {
-      return null;
-    }
-
-    return (
-      <>
-        <OutsideShareLinkModal
-          isOpen={isOutsideShareLinkModalShown}
-          onClose={closeOutsideShareLinkModalHandler}
-        />
-
-        {/* TODO GW-2746 bulk export pages */}
-        {/* <ArchiveCreateModal
-          isOpen={isArchiveCreateModalShown}
-          onClose={closeArchiveCreateModalHandler}
-          path={path}
-          errorMessage={errorMessage}
-          totalPages={totalPages}
-        /> */}
-      </>
-    );
-  }
-
-
-  function renderCurrentUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
-          data-toggle="dropdown"
-        >
-          <i className="icon-share"></i>
-        </button>
-      </>
-    );
-  }
-
-  function renderGuestUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
-          id="auth-guest-tltips"
-        >
-          <i className="icon-share"></i>
-        </button>
-        <UncontrolledTooltip placement="top" target="auth-guest-tltips">
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      </>
-    );
-  }
-
-  return (
-    <>
-      {currentUser == null ? renderGuestUser() : renderCurrentUser()}
-      <div className="dropdown-menu dropdown-menu-right">
-        <button className="dropdown-item" type="button" onClick={openOutsideShareLinkModalHandler}>
-          <i className="icon-fw icon-link"></i>{t('share_links.Shere this page link to public')}
-          <span className="ml-2 badge badge-info badge-pill">{pageContainer.state.shareLinksNumber}</span>
-        </button>
-        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
-          <span>{t('export_bulk.export_page_markdown')}</span>
-        </button>
-        {/* TODO GW-2746 create api to bulk export pages */}
-        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
-          <i className="icon-fw"></i>{t('Create Archive Page')}
-        </button> */}
-      </div>
-      {renderModals()}
-    </>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageShareManagementWrapper = withUnstatedContainers(PageShareManagement, [AppContainer, PageContainer]);
-
-
-PageShareManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withTranslation()(PageShareManagementWrapper);

+ 160 - 0
src/client/js/components/PageAccessoriesModal.jsx

@@ -0,0 +1,160 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Modal, ModalBody, ModalHeader, Nav, NavItem, NavLink, TabContent, TabPane,
+} from 'reactstrap';
+
+import { withTranslation } from 'react-i18next';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import RecentChangesIcon from './Icons/RecentChangesIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+import PageAttachment from './PageAttachment';
+import PageTimeline from './PageTimeline';
+import PageList from './PageList';
+import PageHistory from './PageHistory';
+import ShareLink from './ShareLink/ShareLink';
+
+
+const navTabMapping = {
+  pagelist: {
+    icon: <PageListIcon />,
+    i18n: 'page_list',
+    index: 0,
+  },
+  timeline:  {
+    icon: <TimeLineIcon />,
+    i18n: 'Timeline View',
+    index: 1,
+  },
+  pageHistory: {
+    icon: <RecentChangesIcon />,
+    i18n: 'History',
+    index: 2,
+  },
+  attachment: {
+    icon: <AttachmentIcon />,
+    i18n: 'attachment_data',
+    index: 3,
+  },
+  shareLink: {
+    icon: <ShareLinkIcon />,
+    i18n: 'share_links.share_link_management',
+    index: 4,
+  },
+};
+
+const PageAccessoriesModal = (props) => {
+  const { t, pageAccessoriesContainer } = props;
+  const { switchActiveTab } = pageAccessoriesContainer;
+  const { activeTab } = pageAccessoriesContainer.state;
+
+  const [sliderWidth, setSliderWidth] = useState(null);
+  const [sliderMarginLeft, setSliderMarginLeft] = useState(null);
+
+  function closeModalHandler() {
+    if (props.onClose == null) {
+      return;
+    }
+    props.onClose();
+  }
+
+  // Might make this dynamic for px, %, pt, em
+  function getPercentage(min, max) {
+    return min / max * 100;
+  }
+
+  useEffect(() => {
+    if (activeTab === '') {
+      return;
+    }
+
+    const navTitle = document.getElementById('nav-title');
+    const navTabs = document.querySelectorAll('li.nav-link');
+
+    if (navTitle == null || navTabs == null) {
+      return;
+    }
+
+    let tempML = 0;
+
+    const styles = [].map.call(navTabs, (el) => {
+      const width = getPercentage(el.offsetWidth, navTitle.offsetWidth);
+      const marginLeft = tempML;
+      tempML += width;
+      return { width, marginLeft };
+    });
+
+    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
+
+    setSliderWidth(width);
+    setSliderMarginLeft(marginLeft);
+
+  }, [activeTab]);
+
+
+  return (
+    <React.Fragment>
+      <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className="grw-page-accessories-modal">
+        {/* [TODO: insert a modal header and move nav tabs there  by gw-3890] */}
+        <ModalHeader className="p-0" toggle={closeModalHandler}>
+          <Nav className="nav-title" id="nav-title">
+            {Object.entries(navTabMapping).map(([key, value]) => {
+              return (
+                <NavItem key={key} type="button" className={`p-0 nav-link ${activeTab === key && 'active'}`}>
+                  <NavLink onClick={() => { switchActiveTab(key) }}>
+                    {value.icon}
+                    {t(value.i18n)}
+                  </NavLink>
+                </NavItem>
+              );
+            })}
+          </Nav>
+          <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
+        </ModalHeader>
+        <ModalBody className="overflow-auto grw-modal-body-style p-0">
+          <TabContent activeTab={activeTab} className="p-5">
+            <TabPane tabId="pagelist">
+              {pageAccessoriesContainer.state.activeComponents.has('pagelist') && <PageList />}
+            </TabPane>
+            <TabPane tabId="timeline">
+              {pageAccessoriesContainer.state.activeComponents.has('timeline') && <PageTimeline /> }
+            </TabPane>
+            <TabPane tabId="pageHistory">
+              <div className="overflow-auto">
+                {pageAccessoriesContainer.state.activeComponents.has('pageHistory') && <PageHistory /> }
+              </div>
+            </TabPane>
+            <TabPane tabId="attachment">
+              {pageAccessoriesContainer.state.activeComponents.has('attachment') && <PageAttachment />}
+            </TabPane>
+            <TabPane tabId="shareLink">
+              {pageAccessoriesContainer.state.activeComponents.has('shareLink') && <ShareLink />}
+            </TabPane>
+          </TabContent>
+        </ModalBody>
+      </Modal>
+    </React.Fragment>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageAccessoriesContainer]);
+
+PageAccessoriesModal.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+};
+
+export default withTranslation()(PageAccessoriesModalWrapper);

+ 55 - 19
src/client/js/components/PageAttachment.jsx

@@ -1,9 +1,11 @@
 /* eslint-disable react/no-access-state-in-setstate */
 /* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 
 
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import PaginationWrapper from './PaginationWrapper';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PageContainer from '../services/PageContainer';
@@ -14,6 +16,9 @@ class PageAttachment extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
+      activePage: 1,
+      limit: 10,
+      totalAttachments: 0,
       attachments: [],
       attachments: [],
       inUse: {},
       inUse: {},
       attachmentToDelete: null,
       attachmentToDelete: null,
@@ -21,31 +26,46 @@ class PageAttachment extends React.Component {
       deleteError: '',
       deleteError: '',
     };
     };
 
 
+    this.handlePage = this.handlePage.bind(this);
     this.onAttachmentDeleteClicked = this.onAttachmentDeleteClicked.bind(this);
     this.onAttachmentDeleteClicked = this.onAttachmentDeleteClicked.bind(this);
     this.onAttachmentDeleteClickedConfirm = this.onAttachmentDeleteClickedConfirm.bind(this);
     this.onAttachmentDeleteClickedConfirm = this.onAttachmentDeleteClickedConfirm.bind(this);
   }
   }
 
 
-  componentDidMount() {
+
+  async handlePage(selectedPage) {
     const { pageId } = this.props.pageContainer.state;
     const { pageId } = this.props.pageContainer.state;
+    const { limit } = this.state;
+    const offset = (selectedPage - 1) * limit;
+    const activePage = selectedPage;
+
+    if (!pageId) { return }
+
+    const res = await this.props.appContainer.apiv3Get('/attachment/list', {
+      pageId, limit, offset,
+    });
+    const attachments = res.data.paginateResult.docs;
+    const totalAttachments = res.data.paginateResult.totalDocs;
+
+    const inUse = {};
 
 
-    if (!pageId) {
-      return;
+    for (const attachment of attachments) {
+      inUse[attachment._id] = this.checkIfFileInUse(attachment);
     }
     }
 
 
-    this.props.appContainer.apiGet('/attachments.list', { page_id: pageId })
-      .then((res) => {
-        const attachments = res.attachments;
-        const inUse = {};
+    this.setState({
+      activePage,
+      totalAttachments,
+      attachments,
+      inUse,
+    });
+  }
 
 
-        for (const attachment of attachments) {
-          inUse[attachment._id] = this.checkIfFileInUse(attachment);
-        }
 
 
-        this.setState({
-          attachments,
-          inUse,
-        });
-      });
+  async componentDidMount() {
+    await this.handlePage(1);
+    this.setState({
+      activePage: 1,
+    });
   }
   }
 
 
   checkIfFileInUse(attachment) {
   checkIfFileInUse(attachment) {
@@ -92,7 +112,15 @@ class PageAttachment extends React.Component {
     return this.props.appContainer.currentUser != null;
     return this.props.appContainer.currentUser != null;
   }
   }
 
 
+
   render() {
   render() {
+
+    const { t } = this.props;
+    if (this.state.attachments.length === 0) {
+      return t('No_attachments_yet');
+
+    }
+
     let deleteAttachmentModal = '';
     let deleteAttachmentModal = '';
     if (this.isUserLoggedIn()) {
     if (this.isUserLoggedIn()) {
       const attachmentToDelete = this.state.attachmentToDelete;
       const attachmentToDelete = this.state.attachmentToDelete;
@@ -121,9 +149,8 @@ class PageAttachment extends React.Component {
       );
       );
     }
     }
 
 
-
     return (
     return (
-      <div>
+      <>
         <PageAttachmentList
         <PageAttachmentList
           attachments={this.state.attachments}
           attachments={this.state.attachments}
           inUse={this.state.inUse}
           inUse={this.state.inUse}
@@ -132,7 +159,15 @@ class PageAttachment extends React.Component {
         />
         />
 
 
         {deleteAttachmentModal}
         {deleteAttachmentModal}
-      </div>
+
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalAttachments}
+          pagingLimit={this.state.limit}
+          align="center"
+        />
+      </>
     );
     );
   }
   }
 
 
@@ -145,8 +180,9 @@ const PageAttachmentWrapper = withUnstatedContainers(PageAttachment, [AppContain
 
 
 
 
 PageAttachment.propTypes = {
 PageAttachment.propTypes = {
+  t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
-export default PageAttachmentWrapper;
+export default withTranslation()(PageAttachmentWrapper);

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

@@ -53,21 +53,19 @@ function PageHistory(props) {
 
 
   function pager() {
   function pager() {
     return (
     return (
-      <div className="my-3">
-        <PaginationWrapper
-          activePage={pageHistoryContainer.state.activePage}
-          changePage={handlePage}
-          totalItemsCount={pageHistoryContainer.state.totalPages}
-          pagingLimit={pageHistoryContainer.state.pagingLimit}
-        />
-      </div>
+      <PaginationWrapper
+        activePage={pageHistoryContainer.state.activePage}
+        changePage={handlePage}
+        totalItemsCount={pageHistoryContainer.state.totalPages}
+        pagingLimit={pageHistoryContainer.state.pagingLimit}
+        align="center"
+      />
     );
     );
   }
   }
 
 
 
 
   return (
   return (
-    <div className="mt-4">
-      {pager()}
+    <div>
       <PageRevisionList
       <PageRevisionList
         revisions={pageHistoryContainer.state.revisions}
         revisions={pageHistoryContainer.state.revisions}
         diffOpened={pageHistoryContainer.state.diffOpened}
         diffOpened={pageHistoryContainer.state.diffOpened}

+ 86 - 0
src/client/js/components/PageList.jsx

@@ -0,0 +1,86 @@
+import React, { useEffect, useCallback, useState } from 'react';
+import PropTypes from 'prop-types';
+
+import Page from './PageList/Page';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import PaginationWrapper from './PaginationWrapper';
+
+
+const PageList = (props) => {
+  const { appContainer, pageContainer } = props;
+  const { path } = pageContainer.state;
+  const [pages, setPages] = useState(null);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const [activePage, setActivePage] = useState(1);
+  const [totalPages, setTotalPages] = useState(0);
+  const [limit, setLimit] = useState(appContainer.getConfig().recentCreatedLimit);
+  const [offset, setOffset] = useState(0);
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * limit);
+  }
+
+  const updatePageList = useCallback(async() => {
+    const res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+
+    setPages(res.data.pages);
+    setIsLoading(true);
+    setTotalPages(res.data.totalCount);
+    setLimit(res.data.limit);
+    setOffset(res.data.offset);
+  }, [appContainer, path, limit, offset]);
+
+  useEffect(() => {
+    updatePageList();
+  }, [updatePageList]);
+
+
+  if (isLoading === false) {
+    return (
+      <div className="wiki">
+        <div className="text-muted test-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  const pageList = pages.map(page => (
+    <li key={page._id} className="mb-3">
+      <Page page={page} />
+    </li>
+  ));
+
+  return (
+    <div className="page-list-container-create">
+      <ul className="page-list-ul page-list-ul-flat ml-n4">
+        {pageList}
+      </ul>
+      <PaginationWrapper
+        activePage={activePage}
+        changePage={setPageNumber}
+        totalItemsCount={totalPages}
+        pagingLimit={limit}
+        align="center"
+      />
+    </div>
+  );
+
+
+};
+
+const PageListWrapper = withUnstatedContainers(PageList, [AppContainer, PageContainer]);
+
+
+PageList.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer),
+  pageContainer: PropTypes.instanceOf(PageContainer),
+};
+
+export default PageListWrapper;

+ 31 - 0
src/client/js/components/PagePresentationModal.jsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  Modal, ModalBody,
+} from 'reactstrap';
+
+const PagePresentationModal = (props) => {
+
+  function closeModalHandler() {
+    if (props.onClose === null) {
+      return;
+    }
+    props.onClose();
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeModalHandler} className="grw-presentation-modal" unmountOnClose={false}>
+      <ModalBody className="modal-body">
+        <iframe src={props.href} />
+      </ModalBody>
+    </Modal>
+  );
+};
+PagePresentationModal.propTypes = {
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  href: PropTypes.string.isRequired,
+};
+
+
+export default PagePresentationModal;

+ 61 - 72
src/client/js/components/PageTimeline.jsx

@@ -4,6 +4,8 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+import PaginationWrapper from './PaginationWrapper';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import RevisionLoader from './Page/RevisionLoader';
 import RevisionLoader from './Page/RevisionLoader';
@@ -15,111 +17,98 @@ class PageTimeline extends React.Component {
     super(props);
     super(props);
 
 
     const { appContainer } = this.props;
     const { appContainer } = this.props;
-
     this.state = {
     this.state = {
-      isEnabled: appContainer.getConfig().isEnabledTimeline,
-      isInitialized: false,
+      activePage: 1,
+      totalPageItems: 0,
+      limit: appContainer.getConfig().recentCreatedLimit,
 
 
       // TODO: remove after when timeline is implemented with React and inject data with props
       // TODO: remove after when timeline is implemented with React and inject data with props
       pages: this.props.pages,
       pages: this.props.pages,
     };
     };
 
 
+    this.handlePage = this.handlePage.bind(this);
   }
   }
 
 
-  componentWillMount() {
-    if (!this.state.isEnabled) {
-      return;
-    }
 
 
-    const { appContainer } = this.props;
+  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 res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+    const totalPageItems = res.data.totalCount;
+    const pages = res.data.pages;
+    this.setState({
+      activePage,
+      totalPageItems,
+      pages,
+    });
+  }
 
 
+  componentWillMount() {
+    const { appContainer } = this.props;
     // initialize GrowiRenderer
     // initialize GrowiRenderer
     this.growiRenderer = appContainer.getRenderer('timeline');
     this.growiRenderer = appContainer.getRenderer('timeline');
-
-    this.initBsTab();
   }
   }
 
 
-  /**
-   * initialize Bootstrap Tab event for 'shown.bs.tab'
-   * TODO: remove this method after implement with React
-   */
-  initBsTab() {
-    $('a[data-toggle="tab"][href="#view-timeline"]').on('shown.bs.tab', () => {
-      if (this.state.isInitialized) {
-        return;
-      }
-
-      const pageIdsElm = document.getElementById('page-timeline-data');
-
-      if (pageIdsElm == null || pageIdsElm.text.length === 0) {
-        return;
-      }
-
-      const pages = this.extractDataFromDom();
-
-      this.setState({
-        isInitialized: true,
-        pages,
-      });
+  async componentDidMount() {
+    await this.handlePage(1);
+    this.setState({
+      activePage: 1,
     });
     });
   }
   }
 
 
-  /**
-   * extract page data from DOM
-   * TODO: remove this method after implement with React
-   */
-  extractDataFromDom() {
-    const pageIdsElm = document.getElementById('page-timeline-data');
-
-    if (pageIdsElm == null || pageIdsElm.text.length === 0) {
-      return null;
-    }
-
-    return JSON.parse(pageIdsElm.text);
-  }
-
   render() {
   render() {
-    if (!this.state.isEnabled) {
-      return <React.Fragment></React.Fragment>;
-    }
-
     const { pages } = this.state;
     const { pages } = this.state;
-
     if (pages == null) {
     if (pages == null) {
       return <React.Fragment></React.Fragment>;
       return <React.Fragment></React.Fragment>;
     }
     }
 
 
-    return pages.map((page) => {
-      return (
-        <div className="timeline-body" key={`key-${page.id}`}>
-          <div className="card card-timeline">
-            <div className="card-header"><a href={page.path}>{page.path}</a></div>
-            <div className="card-body">
-              <RevisionLoader
-                lazy
-                growiRenderer={this.growiRenderer}
-                pageId={page.id}
-                revisionId={page.revision}
-              />
+    return (
+      <div>
+        { pages.map((page) => {
+          return (
+            <div className="timeline-body" key={`key-${page.id}`}>
+              <div className="card card-timeline">
+                <div className="card-header"><a href={page.path}>{page.path}</a></div>
+                <div className="card-body">
+                  <RevisionLoader
+                    lazy
+                    growiRenderer={this.growiRenderer}
+                    pageId={page.id}
+                    revisionId={page.revision}
+                  />
+                </div>
+              </div>
             </div>
             </div>
-          </div>
-        </div>
-      );
-    });
+          );
+        }) }
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalPageItems}
+          pagingLimit={this.state.limit}
+          align="center"
+        />
+      </div>
+    );
 
 
   }
   }
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageTimelineWrapper = withUnstatedContainers(PageTimeline, [AppContainer, PageContainer]);
+
 PageTimeline.propTypes = {
 PageTimeline.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pages: PropTypes.arrayOf(PropTypes.object),
   pages: PropTypes.arrayOf(PropTypes.object),
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageTimelineWrapper = withUnstatedContainers(PageTimeline, [AppContainer]);
-
 export default withTranslation()(PageTimelineWrapper);
 export default withTranslation()(PageTimelineWrapper);

+ 19 - 1
src/client/js/components/PaginationWrapper.jsx

@@ -143,6 +143,20 @@ class PaginationWrapper extends React.Component {
 
 
   }
   }
 
 
+  getListClassName() {
+    const listClassNames = [];
+
+    const { align } = this.props;
+    if (align === 'center') {
+      listClassNames.push('justify-content-center');
+    }
+    if (align === 'right') {
+      listClassNames.push('justify-content-end');
+    }
+
+    return listClassNames.join(' ');
+  }
+
   render() {
   render() {
     const paginationItems = [];
     const paginationItems = [];
 
 
@@ -159,7 +173,7 @@ class PaginationWrapper extends React.Component {
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
-        <Pagination size="sm">{paginationItems}</Pagination>
+        <Pagination size="sm" listClassName={this.getListClassName()}>{paginationItems}</Pagination>
       </React.Fragment>
       </React.Fragment>
     );
     );
   }
   }
@@ -176,6 +190,10 @@ PaginationWrapper.propTypes = {
   changePage: PropTypes.func.isRequired,
   changePage: PropTypes.func.isRequired,
   totalItemsCount: PropTypes.number.isRequired,
   totalItemsCount: PropTypes.number.isRequired,
   pagingLimit: PropTypes.number.isRequired,
   pagingLimit: PropTypes.number.isRequired,
+  align: PropTypes.string,
+};
+PaginationWrapper.defaultProps = {
+  align: 'left',
 };
 };
 
 
 export default withTranslation()(PaginationWrappered);
 export default withTranslation()(PaginationWrappered);

+ 29 - 43
src/client/js/components/OutsideShareLinkModal.jsx → src/client/js/components/ShareLink/ShareLink.jsx

@@ -1,23 +1,18 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import AppContainer from '../services/AppContainer';
-import PageContainer from '../services/PageContainer';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
 
 
 import ShareLinkList from './ShareLinkList';
 import ShareLinkList from './ShareLinkList';
 import ShareLinkForm from './ShareLinkForm';
 import ShareLinkForm from './ShareLinkForm';
 
 
-import { toastSuccess, toastError } from '../util/apiNotification';
+import { toastSuccess, toastError } from '../../util/apiNotification';
 
 
-class OutsideShareLinkModal extends React.Component {
+class ShareLink extends React.Component {
 
 
   constructor() {
   constructor() {
     super();
     super();
@@ -90,33 +85,27 @@ class OutsideShareLinkModal extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
-      <Modal size="xl" isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-primary text-light">{t('share_links.Shere this page link to public')}
-        </ModalHeader>
-        <ModalBody>
-          <div className="container">
-            <h3 className="grw-modal-head  d-flex  pb-2">
-              { t('share_links.share_link_list') }
-              <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>
-            </h3>
-
-            <div>
-              <ShareLinkList
-                shareLinks={this.state.shareLinks}
-                onClickDeleteButton={this.deleteLinkById}
-              />
-              <button
-                className="btn btn-outline-secondary d-block mx-auto px-5 mb-3"
-                type="button"
-                onClick={this.toggleShareLinkFormHandler}
-              >
-                {this.state.isOpenShareLinkForm ? t('Close') : t('New')}
-              </button>
-              {this.state.isOpenShareLinkForm && <ShareLinkForm onCloseForm={this.toggleShareLinkFormHandler} />}
-            </div>
-          </div>
-        </ModalBody>
-      </Modal>
+      <div className="container p-0">
+        <h3 className="grw-modal-head d-flex pb-2">
+          { t('share_links.share_link_list') }
+          <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>
+        </h3>
+
+        <div>
+          <ShareLinkList
+            shareLinks={this.state.shareLinks}
+            onClickDeleteButton={this.deleteLinkById}
+          />
+          <button
+            className="btn btn-outline-secondary d-block mx-auto px-5"
+            type="button"
+            onClick={this.toggleShareLinkFormHandler}
+          >
+            {this.state.isOpenShareLinkForm ? t('Close') : t('New')}
+          </button>
+          {this.state.isOpenShareLinkForm && <ShareLinkForm onCloseForm={this.toggleShareLinkFormHandler} />}
+        </div>
+      </div>
     );
     );
   }
   }
 
 
@@ -125,15 +114,12 @@ class OutsideShareLinkModal extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ModalControlWrapper = withUnstatedContainers(OutsideShareLinkModal, [AppContainer, PageContainer]);
+const ShareLinkWrapper = withUnstatedContainers(ShareLink, [AppContainer, PageContainer]);
 
 
-OutsideShareLinkModal.propTypes = {
+ShareLink.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(ModalControlWrapper);
+export default withTranslation()(ShareLinkWrapper);

+ 4 - 4
src/client/js/components/ShareLinkForm.jsx → src/client/js/components/ShareLink/ShareLinkForm.jsx

@@ -6,12 +6,12 @@ import dateFnsFormat from 'date-fns/format';
 import parse from 'date-fns/parse';
 import parse from 'date-fns/parse';
 
 
 import { isInteger } from 'core-js/fn/number';
 import { isInteger } from 'core-js/fn/number';
-import { withUnstatedContainers } from './UnstatedUtils';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-import { toastSuccess, toastError } from '../util/apiNotification';
+import { toastSuccess, toastError } from '../../util/apiNotification';
 
 
-import AppContainer from '../services/AppContainer';
-import PageContainer from '../services/PageContainer';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
 
 
 class ShareLinkForm extends React.Component {
 class ShareLinkForm extends React.Component {
 
 

+ 3 - 3
src/client/js/components/ShareLinkList.jsx → src/client/js/components/ShareLink/ShareLinkList.jsx

@@ -5,10 +5,10 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-import AppContainer from '../services/AppContainer';
-import CopyDropdown from './Page/CopyDropdown';
+import AppContainer from '../../services/AppContainer';
+import CopyDropdown from '../Page/CopyDropdown';
 
 
 const ShareLinkList = (props) => {
 const ShareLinkList = (props) => {
 
 

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

@@ -8,6 +8,7 @@ import PageContainer from '../services/PageContainer';
 import NavigationContainer from '../services/NavigationContainer';
 import NavigationContainer from '../services/NavigationContainer';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
+import TopOfTableContents from './TopOfTableContents';
 import StickyStretchableScroller from './StickyStretchableScroller';
 import StickyStretchableScroller from './StickyStretchableScroller';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
@@ -26,8 +27,8 @@ const TableOfContents = (props) => {
     const containerElem = document.querySelector('#revision-toc');
     const containerElem = document.querySelector('#revision-toc');
     const containerTop = containerElem.getBoundingClientRect().top;
     const containerTop = containerElem.getBoundingClientRect().top;
 
 
-    // window height - revisionToc top - .system-version - .grw-fab-container height
-    return window.innerHeight - containerTop - 20 - 155;
+    // window height - revisionToc top - .system-version - .grw-fab-container height - top-of-table-contents height
+    return window.innerHeight - containerTop - 20 - 155 - 26;
   }, []);
   }, []);
 
 
   const { tocHtml } = pageContainer.state;
   const { tocHtml } = pageContainer.state;
@@ -41,7 +42,7 @@ const TableOfContents = (props) => {
 
 
   return (
   return (
     <>
     <>
-      {/* TODO GW-3253 add four contents */}
+      <TopOfTableContents />
       <StickyStretchableScroller
       <StickyStretchableScroller
         contentsElemSelector=".revision-toc .markdownIt-TOC"
         contentsElemSelector=".revision-toc .markdownIt-TOC"
         stickyElemSelector="#revision-toc"
         stickyElemSelector="#revision-toc"

+ 95 - 0
src/client/js/components/TopOfTableContents.jsx

@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import RecentChangesIcon from './Icons/RecentChangesIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+
+import PageAccessoriesModal from './PageAccessoriesModal';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+const TopOfTableContents = (props) => {
+  const { pageAccessoriesContainer } = props;
+
+  function renderModal() {
+    return (
+      <>
+        <PageAccessoriesModal
+          isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
+          onClose={pageAccessoriesContainer.closePageAccessoriesModal}
+        />
+      </>
+    );
+  }
+
+  return (
+    <>
+      <div className="top-of-table-contents d-flex align-items-end pb-1">
+        <button
+          type="button"
+          className="btn btn-link grw-btn-top-of-table"
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pagelist')}
+        >
+          <PageListIcon />
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-link grw-btn-top-of-table"
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('timeline')}
+        >
+          <TimeLineIcon />
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-link grw-btn-top-of-table"
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
+        >
+          <RecentChangesIcon />
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-link grw-btn-top-of-table"
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
+        >
+          <AttachmentIcon />
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-link grw-btn-top-of-table"
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
+        >
+          <ShareLinkIcon />
+        </button>
+
+        <div
+          id="seen-user-list"
+          data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
+          data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
+        >
+        </div>
+      </div>
+      {renderModal()}
+    </>
+  );
+};
+/**
+ * Wrapper component for using unstated
+ */
+const TopOfTableContentsWrapper = withUnstatedContainers(TopOfTableContents, [PageAccessoriesContainer]);
+
+TopOfTableContents.propTypes = {
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+};
+
+export default withTranslation()(TopOfTableContentsWrapper);

+ 29 - 19
src/client/js/components/User/SeenUserList.jsx

@@ -1,30 +1,40 @@
-import React from 'react';
+// import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import React, { useState } from 'react';
+import {
+  Button, Popover, PopoverBody,
+} from 'reactstrap';
 import UserPictureList from './UserPictureList';
 import UserPictureList from './UserPictureList';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import PageContainer from '../../services/PageContainer';
 import PageContainer from '../../services/PageContainer';
 
 
-class SeenUserList extends React.Component {
-
-  render() {
-    const { pageContainer } = this.props;
-    return (
-      <div className="user-list-content text-truncate text-muted text-right">
-        <span className="text-danger">
-          <span className="seen-user-count">{pageContainer.state.sumOfSeenUsers}</span>
-          <i className="fa fa-fw fa-paw"></i>
-        </span>
-        <span className="mr-1">
-          <UserPictureList users={pageContainer.state.seenUsers} />
-        </span>
-      </div>
-    );
-  }
-
-}
+import FootstampIcon from '../FootstampIcon';
+
+/* eslint react/no-multi-comp: 0, react/prop-types: 0 */
+
+const SeenUserList = (props) => {
+  const [popoverOpen, setPopoverOpen] = useState(false);
+  const toggle = () => setPopoverOpen(!popoverOpen);
+  const { pageContainer } = props;
+  return (
+    <div className="grw-seen-user-list pl-2 ml-2">
+      <Button id="po-seen-user" color="link" className="px-2">
+        <span className="mr-1 footstamp-icon"><FootstampIcon /></span>
+        <span className="seen-user-count">{pageContainer.state.countOfSeenUsers}</span>
+      </Button>
+      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            <UserPictureList users={pageContainer.state.seenUsers} />
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
 
 
 SeenUserList.propTypes = {
 SeenUserList.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,

+ 0 - 23
src/client/js/legacy/crowi.js

@@ -197,29 +197,6 @@ $(() => {
         }
         }
       });
       });
     }
     }
-
-    // presentation
-    let presentaionInitialized = false;
-
-
-    const $b = $('body');
-
-    $(document).on('click', '.toggle-presentation', function(e) {
-      const $a = $(this);
-
-      e.preventDefault();
-      $b.toggleClass('overlay-on');
-
-      if (!presentaionInitialized) {
-        presentaionInitialized = true;
-
-        $('<iframe />').attr({
-          src: $a.attr('href'),
-        }).appendTo($('#presentation-container'));
-      }
-    }).on('click', '.fullscreen-layer', () => {
-      $b.toggleClass('overlay-on');
-    });
   } // end if pageId
   } // end if pageId
 
 
   // tab changing handling
   // tab changing handling

+ 54 - 0
src/client/js/services/PageAccessoriesContainer.js

@@ -0,0 +1,54 @@
+import { Container } from 'unstated';
+
+/**
+ * Service container related to options for Application
+ * @extends {Container} unstated Container
+ */
+
+export default class PageAccessoriesContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      isPageAccessoriesModalShown: false,
+      activeTab: '',
+      // Prevent unnecessary rendering
+      activeComponents: new Set(['']),
+    };
+    this.openPageAccessoriesModal = this.openPageAccessoriesModal.bind(this);
+    this.closePageAccessoriesModal = this.closePageAccessoriesModal.bind(this);
+    this.switchActiveTab = this.switchActiveTab.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PageAccessoriesContainer';
+  }
+
+
+  openPageAccessoriesModal(activeTab) {
+    this.setState({
+      isPageAccessoriesModalShown: true,
+    });
+    this.switchActiveTab(activeTab);
+  }
+
+  closePageAccessoriesModal() {
+    this.setState({
+      isPageAccessoriesModalShown: false,
+      activeTab: '',
+    });
+  }
+
+  switchActiveTab(activeTab) {
+    this.setState({
+      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
+    });
+  }
+
+}

+ 12 - 16
src/client/js/services/PageContainer.js

@@ -49,10 +49,14 @@ export default class PageContainer extends Container {
       path,
       path,
       tocHtml: '',
       tocHtml: '',
       isLiked: JSON.parse(mainContent.getAttribute('data-page-is-liked')),
       isLiked: JSON.parse(mainContent.getAttribute('data-page-is-liked')),
+
+      seenUserIds: mainContent.getAttribute('data-page-ids-of-seen-users'),
       seenUsers: [],
       seenUsers: [],
+      countOfSeenUsers: mainContent.getAttribute('data-page-count-of-seen-users'),
+
       likerUsers: [],
       likerUsers: [],
-      sumOfSeenUsers: 0,
       sumOfLikers: 0,
       sumOfLikers: 0,
+
       createdAt: mainContent.getAttribute('data-page-created-at'),
       createdAt: mainContent.getAttribute('data-page-created-at'),
       creator: JSON.parse(mainContent.getAttribute('data-page-creator')),
       creator: JSON.parse(mainContent.getAttribute('data-page-creator')),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
@@ -81,6 +85,7 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
     interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
 
+    this.retrieveSeenUsers();
     this.initStateMarkdown();
     this.initStateMarkdown();
     this.initStateOthers();
     this.initStateOthers();
 
 
@@ -127,23 +132,14 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
     this.state.markdown = markdown;
   }
   }
 
 
-  async initStateOthers() {
-
-    const seenUserListElem = document.getElementById('seen-user-list');
-    if (seenUserListElem != null) {
-      const { userIdsStr, sumOfSeenUsers } = seenUserListElem.dataset;
-      this.setState({ sumOfSeenUsers });
+  async retrieveSeenUsers() {
+    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: this.state.seenUserIds });
 
 
-      if (userIdsStr === '') {
-        return;
-      }
-
-      const { users } = await this.appContainer.apiGet('/users.list', { user_ids: userIdsStr });
-      this.setState({ seenUsers: users });
-
-      this.checkAndUpdateImageUrlCached(users);
-    }
+    this.setState({ seenUsers: users });
+    this.checkAndUpdateImageUrlCached(users);
+  }
 
 
+  async initStateOthers() {
 
 
     const likerListElem = document.getElementById('liker-list');
     const likerListElem = document.getElementById('liker-list');
     if (likerListElem != null) {
     if (likerListElem != null) {

+ 0 - 24
src/client/styles/scss/_layout.scss

@@ -32,30 +32,6 @@ body {
   margin-top: 1rem;
   margin-top: 1rem;
 }
 }
 
 
-.revision-toc {
-  // to get on the Attachment row
-  z-index: 1;
-  overflow: hidden;
-  font-size: 0.9em;
-
-  .revision-toc-content {
-    padding: 10px;
-
-    > ul {
-      padding-left: 0;
-      ul {
-        padding-left: 1em;
-      }
-    }
-
-    // first level of li
-    > ul > li {
-      padding: 5px;
-      margin: 4px 4px 4px 17px;
-    }
-  }
-}
-
 .grw-fab {
 .grw-fab {
   position: fixed;
   position: fixed;
   right: 1.5rem;
   right: 1.5rem;

+ 6 - 4
src/client/styles/scss/_layout_growi.scss

@@ -5,9 +5,7 @@
     padding: 0;
     padding: 0;
   }
   }
 
 
-  .liker-and-seenusers {
-    // adjusting position with negative margin
-    height: $grw-nav-main-tab-height;
+  .top-of-table-contents {
     line-height: 1.25;
     line-height: 1.25;
     border-bottom: 1px solid transparent;
     border-bottom: 1px solid transparent;
 
 
@@ -16,9 +14,13 @@
 
 
       .liker-user-count,
       .liker-user-count,
       .seen-user-count {
       .seen-user-count {
-        font-weight: bold;
+        font-size: 12px;
+        font-weight: bolder;
       }
       }
     }
     }
+    .cls-1 {
+      isolation: isolate;
+    }
   }
   }
 
 
   .revision-toc {
   .revision-toc {

+ 13 - 0
src/client/styles/scss/_page-presentation.scss

@@ -0,0 +1,13 @@
+.grw-presentation-modal {
+  @include expand-modal-fullscreen(false, false);
+
+  .modal-body {
+    background: black;
+
+    iframe {
+      width: 100%;
+      height: 100%;
+      border: 0;
+    }
+  }
+}

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

@@ -135,51 +135,6 @@
   }
   }
 }
 }
 
 
-/*
- * for Presentation
- */
-.fullscreen-layer {
-  position: fixed;
-  top: 0;
-  left: 0;
-  z-index: 9999;
-  width: 100%;
-  height: 0;
-  background: rgba(0, 0, 0, 0.5);
-  opacity: 0;
-  transition: opacity 0.3s ease-out;
-
-  & > * {
-    box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
-  }
-}
-
-.overlay-on {
-  #wrapper {
-    filter: blur(5px);
-  }
-
-  .fullscreen-layer {
-    height: 100%;
-    opacity: 1;
-  }
-}
-
-#presentation-container {
-  position: absolute;
-  top: 5%;
-  left: 5%;
-  width: 90%;
-  height: 90%;
-  background: black;
-
-  iframe {
-    width: 100%;
-    height: 100%;
-    border: 0;
-  }
-}
-
 .card.grw-page-status-alert {
 .card.grw-page-status-alert {
   $margin-bottom: $grw-navbar-bottom-height + 10px;
   $margin-bottom: $grw-navbar-bottom-height + 10px;
 
 

+ 40 - 0
src/client/styles/scss/_page_accessaries_modal.scss

@@ -0,0 +1,40 @@
+.grw-page-accessories-modal {
+  .nav-title {
+    flex-wrap: nowrap;
+
+    li {
+      a.nav-link {
+        padding: 1rem 1.5rem;
+      }
+    }
+  }
+  .modal-header {
+    button.close {
+      margin: auto 0rem auto auto;
+    }
+  }
+
+  .grw-nav-slide-hr {
+    border-top: 0rem;
+    border-bottom: 3px solid;
+    transition: 0.3s ease-in-out;
+  }
+  .nav-link svg {
+    width: 17px;
+    height: 17px;
+    margin-right: 5px;
+  }
+
+  .grw-modal-body-style {
+    max-height: calc(100vh - 100px);
+  }
+  ul.pagination {
+    margin-bottom: 0rem;
+  }
+}
+
+// revision-history
+// to stay d2h-code-side-line-number in the revision history diff area
+.d2h-wrapper {
+  position: relative;
+}

+ 49 - 0
src/client/styles/scss/_toc.scss

@@ -0,0 +1,49 @@
+.top-of-table-contents {
+  flex-wrap: wrap;
+
+  .grw-btn-top-of-table {
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+  .seen-user-count {
+    font-size: 12px;
+    font-weight: bolder;
+  }
+  .grw-seen-user-list {
+    border-left: 1px solid;
+
+    .btn {
+      white-space: nowrap;
+    }
+  }
+
+  .seen-user-popover {
+    max-width: 200px;
+  }
+}
+
+.revision-toc {
+  // to get on the Attachment row
+  z-index: 1;
+  font-size: 0.9em;
+
+  .revision-toc-content {
+    padding: 10px;
+
+    > ul {
+      padding-left: 0;
+      ul {
+        padding-left: 1em;
+      }
+    }
+
+    // first level of li
+    > ul > li {
+      padding: 5px;
+      margin: 4px 4px 4px 17px;
+    }
+  }
+}

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

@@ -49,16 +49,19 @@
 @import 'page_list';
 @import 'page_list';
 @import 'page-path';
 @import 'page-path';
 @import 'page';
 @import 'page';
+@import 'page-presentation';
 @import 'search';
 @import 'search';
 @import 'shortcuts';
 @import 'shortcuts';
 @import 'sidebar';
 @import 'sidebar';
 @import 'subnav';
 @import 'subnav';
 @import 'tag';
 @import 'tag';
+@import 'toc';
 @import 'user';
 @import 'user';
 @import 'user_growi';
 @import 'user_growi';
 @import 'staff_credit';
 @import 'staff_credit';
 @import 'waves';
 @import 'waves';
 @import 'wiki';
 @import 'wiki';
+@import 'page_accessaries_modal';
 @import 'sharelink';
 @import 'sharelink';
 @import 'linkedit-preview';
 @import 'linkedit-preview';
 
 

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

@@ -14,6 +14,8 @@ $bordercolor-nav-tabs: $gray-300 !default;
 $bordercolor-nav-tabs-hover: $gray-200 $gray-200 $bordercolor-nav-tabs !default;
 $bordercolor-nav-tabs-hover: $gray-200 $gray-200 $bordercolor-nav-tabs !default;
 $color-nav-tabs-link-active: $gray-600 !default;
 $color-nav-tabs-link-active: $gray-600 !default;
 $bordercolor-nav-tabs-active: $bordercolor-nav-tabs $bordercolor-nav-tabs $bgcolor-global !default;
 $bordercolor-nav-tabs-active: $bordercolor-nav-tabs $bordercolor-nav-tabs $bgcolor-global !default;
+$bordercolor-toc: $bordercolor-nav-tabs !default;
+$color-seen-user: #549c79 !default;
 
 
 // override bootstrap variables
 // override bootstrap variables
 $body-bg: $bgcolor-global;
 $body-bg: $bgcolor-global;
@@ -257,8 +259,46 @@ pre:not(.hljs):not(.CodeMirror-line) {
   }
   }
 }
 }
 
 
-.liker-and-seenusers {
-  border-bottom-color: $bordercolor-nav-tabs;
+.top-of-table-contents {
+  border-color: $bordercolor-toc;
+
+  .grw-btn-top-of-table {
+    fill: $color-link;
+  }
+  .grw-seen-user-list {
+    border-color: $bordercolor-toc;
+
+    .btn {
+      color: $color-seen-user;
+      &:active {
+        color: $color-seen-user;
+      }
+      .footstamp-icon {
+        fill: $color-seen-user;
+      }
+    }
+  }
+}
+
+.grw-page-accessories-modal {
+  .nav-title {
+    color: $color-link;
+  }
+  .modal-header {
+    button.close {
+      color: $secondary;
+    }
+  }
+  .nav-link svg {
+    fill: $color-link;
+  }
+  .modal-split-hr {
+    background-color: $bordercolor-nav-tabs;
+  }
+
+  .grw-nav-slide-hr {
+    border-color: $color-link;
+  }
 }
 }
 
 
 /*
 /*

+ 38 - 0
src/migrations/20200903080025-remove-timeline-type.js.js

@@ -0,0 +1,38 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:remove-behavior-type');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    await Config.findOneAndDelete({ key: 'customize:timeline' }); // remove timeline
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    // do not rollback
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const insertConfig = new Config({
+      ns: 'crowi',
+      key: 'customize:timeline',
+      value: true,
+    });
+
+    await insertConfig.save();
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

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

@@ -7,6 +7,7 @@ const path = require('path');
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
+const mongoosePaginate = require('mongoose-paginate-v2');
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
@@ -29,6 +30,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
     createdAt: { type: Date, default: Date.now },
   });
   });
   attachmentSchema.plugin(uniqueValidator);
   attachmentSchema.plugin(uniqueValidator);
+  attachmentSchema.plugin(mongoosePaginate);
 
 
   attachmentSchema.virtual('filePathProxied').get(function() {
   attachmentSchema.virtual('filePathProxied').get(function() {
     return `/attachment/${this._id}`;
     return `/attachment/${this._id}`;
@@ -63,5 +65,6 @@ module.exports = function(crowi) {
     return attachment;
     return attachment;
   };
   };
 
 
+
   return mongoose.model('Attachment', attachmentSchema);
   return mongoose.model('Attachment', attachmentSchema);
 };
 };

+ 91 - 0
src/server/routes/apiv3/attachment.js

@@ -0,0 +1,91 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+const { query } = require('express-validator');
+
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Attachment
+ */
+
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi);
+  const Page = crowi.model('Page');
+  const User = crowi.model('User');
+  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 }),
+    ],
+  };
+  /**
+   * @swagger
+   *
+   *    /attachment/list:
+   *      get:
+   *        tags: [Attachment]
+   *        description: Get attachment list
+   *        responses:
+   *          200:
+   *            description: Return attachment list
+   *        parameters:
+   *          - name: page_id
+   *            in: query
+   *            required: true
+   *            description: page id
+   *            schema:
+   *              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;
+
+    try {
+      const pageId = req.query.pageId;
+      // check whether accessible
+      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+      if (!isAccessible) {
+        const msg = 'Current user is not accessible to this page.';
+        return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
+      }
+
+      const paginateResult = await Attachment.paginate(
+        { page: pageId },
+        {
+          limit,
+          offset,
+          populate: {
+            path: 'creator',
+            select: User.USER_PUBLIC_FIELDS,
+          },
+        },
+      );
+      paginateResult.docs.forEach((doc) => {
+        if (doc.creator != null && doc.creator instanceof User) {
+          doc.creator = doc.creator.toObject();
+        }
+      });
+
+      return res.apiv3({ paginateResult });
+    }
+    catch (err) {
+      logger.error('Attachment not found', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  return router;
+};

+ 1 - 0
src/server/routes/apiv3/index.js

@@ -44,6 +44,7 @@ module.exports = (crowi) => {
   router.use('/share-links', require('./share-links')(crowi));
   router.use('/share-links', require('./share-links')(crowi));
 
 
   router.use('/bookmarks', require('./bookmarks')(crowi));
   router.use('/bookmarks', require('./bookmarks')(crowi));
+  router.use('/attachment', require('./attachment')(crowi));
 
 
   return router;
   return router;
 };
 };

+ 18 - 0
src/server/routes/apiv3/pages.js

@@ -7,12 +7,14 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
+
 /**
 /**
  * @swagger
  * @swagger
  *  tags:
  *  tags:
  *    name: Pages
  *    name: Pages
  */
  */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
@@ -82,5 +84,21 @@ 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;
+    const queryOptions = { offset, limit };
+
+    try {
+      const result = await Page.findListWithDescendants(path, req.user, queryOptions);
+      return res.apiv3(result);
+    }
+    catch (err) {
+      logger.error('Failed to get Descendants Pages', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
   return router;
   return router;
 };
 };

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

@@ -127,7 +127,6 @@ const ApiResponse = require('../util/apiResponse');
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const Attachment = crowi.model('Attachment');
   const Attachment = crowi.model('Attachment');
-  const User = crowi.model('User');
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const { fileUploadService, attachmentService } = crowi;
   const { fileUploadService, attachmentService } = crowi;
 
 
@@ -295,63 +294,6 @@ module.exports = function(crowi, app) {
     return responseForAttachment(req, res, attachment);
     return responseForAttachment(req, res, attachment);
   };
   };
 
 
-  /**
-   * @swagger
-   *
-   *    /attachments.list:
-   *      get:
-   *        tags: [Attachments, CrowiCompatibles]
-   *        operationId: listAttachments
-   *        summary: /attachments.list
-   *        description: Get list of attachments in page
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *            required: true
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of attachments.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    attachments:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/Attachment'
-   *                      description: attachment list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /attachments.list Get attachments of the page
-   * @apiName ListAttachments
-   * @apiGroup Attachment
-   *
-   * @apiParam {String} page_id
-   */
-  api.list = async function(req, res) {
-    const id = req.query.page_id || null;
-    if (!id) {
-      return res.json(ApiResponse.error('Parameters page_id is required.'));
-    }
-
-    let attachments = await Attachment.find({ page: id })
-      .sort({ updatedAt: 1 })
-      .populate({ path: 'creator', select: User.USER_PUBLIC_FIELDS });
-
-    attachments = attachments.map((attachment) => {
-      return attachment.toObject({ virtuals: true });
-    });
-
-    return res.json(ApiResponse.success({ attachments }));
-  };
 
 
   /**
   /**
    * @swagger
    * @swagger

+ 0 - 1
src/server/routes/index.js

@@ -155,7 +155,6 @@ module.exports = function(crowi, app) {
   app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
   app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
   app.post('/_api/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);
   app.post('/_api/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, comment.api.remove);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, comment.api.remove);
-  app.get('/_api/attachments.list'    , accessTokenParser , loginRequired , attachment.api.list);
   app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
   app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
   app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
   app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
   app.post('/_api/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);
   app.post('/_api/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);

+ 3 - 8
src/server/views/layout-growi/page.html

@@ -12,17 +12,12 @@
 
 
       {% include '../widget/page_content.html' %}
       {% include '../widget/page_content.html' %}
 
 
-      <div class="page-list d-edit-none d-print-none mt-5">
-        {% include '../widget/page_list_and_timeline.html' %}
-      </div>
-
     </div>
     </div>
 
 
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
-      {% include './widget/liker-and-seenusers.html' %}
-      <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
-        <div id="revision-toc-content" class="revision-toc-content"></div>
-      </div>
+        <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
     </div>
     </div>
 
 
   </div>
   </div>

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

@@ -12,14 +12,9 @@
 
 
       {% include '../widget/page_content.html' %}
       {% include '../widget/page_content.html' %}
 
 
-      <div class="page-list d-edit-none d-print-none mt-5">
-        {% include '../widget/page_list_and_timeline.html' %}
-      </div>
-
     </div>
     </div>
 
 
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
-      {% include './widget/liker-and-seenusers.html' %}
       <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
       <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
         <div id="revision-toc-content" class="revision-toc-content"></div>
         <div id="revision-toc-content" class="revision-toc-content"></div>
       </div>
       </div>

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

@@ -35,13 +35,6 @@
 
 
     {# relocate #revision-toc #}
     {# relocate #revision-toc #}
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
     <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
-      <div class="liker-and-seenusers d-flex align-items-end justify-content-end">
-        <div
-          id="seen-user-list"
-          data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
-          data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
-        ></div>
-      </div>
       <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="116">
       <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="116">
         <div id="revision-toc-content" class="revision-toc-content"></div>
         <div id="revision-toc-content" class="revision-toc-content"></div>
       </div>
       </div>
@@ -49,11 +42,6 @@
 
 
   </div>
   </div>
 
 
-  <div class="row page-list d-edit-none d-print-none mt-5">
-    <div class="col-md-10">
-      {% include '../widget/page_list_and_timeline.html' %}
-    </div>
-  </div>
 
 
 {% endblock %}
 {% endblock %}
 
 

+ 0 - 12
src/server/views/layout-growi/widget/liker-and-seenusers.html

@@ -1,12 +0,0 @@
-<div class="liker-and-seenusers">
-  <div
-    id="liker-list"
-    data-user-ids-str="{{ page.liker|slice(-15)|default([])|reverse|join(',') }}"
-    data-sum-of-likers="{{ page.liker.length|default(0) }}"
-  ></div>
-  <div
-    id="seen-user-list"
-    data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
-    data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
-  ></div>
-</div>

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

@@ -21,11 +21,6 @@
 
 
 </div>
 </div>
 
 
-  <div class="row page-list grw-pt-10px my-5 round-corner d-edit-none">
-    <div class="col-md-10">
-      {% include '../widget/page_list_and_timeline.html' %}
-    </div>
-  </div>
 {% endblock %}
 {% endblock %}
 
 
 
 

+ 2 - 0
src/server/views/widget/page_content.html

@@ -24,6 +24,8 @@
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
+  data-page-ids-of-seen-users="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
+  data-page-count-of-seen-users="{{ page.seenUsers.length|default(0) }}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
   >
   >

+ 0 - 39
src/server/views/widget/page_list_and_timeline.html

@@ -1,39 +0,0 @@
-<div class="page-list-container">
-  <ul class="nav nav-tabs" role="tablist">
-      <li class="nav-item">
-        <a class="nav-link active" href="#view-list" role="tab" data-toggle="tab">{{ t('List View') }}</a>
-      </li>
-      {% if getConfig('crowi', 'customize:isEnabledTimeline') %}
-      <li class="nav-item">
-        <a class="nav-link" href="#view-timeline" role="tab" data-toggle="tab">{{ t('Timeline View') }}</a>
-      </li>
-      {% endif %}
-  </ul>
-
-  <div class="tab-content">
-
-    {# list view #}
-    <div class="pt-2 active tab-pane page-list-container fade show" id="view-list">
-      {% if pages.length == 0 %}
-        <div class="mt-2">
-          {% if isTrashPage() %}
-          No deleted pages.
-          {% else %}
-          There are no pages under <strong>{{ path | preventXss }}</strong>.
-          {% endif %}
-        </div>
-      {% else %}
-        {% include 'page_list.html' with { pages: pages, pager: pager, viewConfig: viewConfig } %}
-      {% endif %}
-    </div>
-
-    {# timeline view #}
-    {% if getConfig('crowi', 'customize:isEnabledTimeline') %}
-      <div class="tab-pane mt-5" id="view-timeline">
-        <script type="text/template" id="page-timeline-data">{{ JSON.stringify(pagesDataForTimeline(pages)) | preventXss }}</script>
-        {# render React Component PageTimeline #}
-        <div id="page-timeline"></div>
-      </div>
-    {% endif %}
-  </div>
-</div>

+ 0 - 38
src/server/views/widget/page_list_and_timeline_kibela.html

@@ -1,38 +0,0 @@
-<div class="page-list-container">
-  <ul class="nav nav-tabs" role="tablist">
-      <li class="nav-item">
-        <a class="nav-link active" href="#view-list" role="tab" data-toggle="tab">{{ t('List View') }}</a>
-      </li>
-      {% if getConfig('crowi', 'customize:isEnabledTimeline') %}
-      <li class="nav-item">
-        <a class="nav-link" href="#view-timeline" role="tab" data-toggle="tab">{{ t('Timeline View') }}</a>
-      </li>
-      {% endif %}
-  </ul>
-
-  <div class="tab-content">
-    {# list view #}
-    <div class="pt-2 active tab-pane page-list-container fade show" id="view-list">
-      {% if pages.length == 0 %}
-        <div class="mt-2">
-          {% if isTrashPage() %}
-          No deleted pages.
-          {% else %}
-          There are no pages under <strong>{{ path | preventXss }}</strong>.
-          {% endif %}
-        </div>
-      {% else %}
-        {% include 'page_list.html' with { pages: pages, pager: pager, viewConfig: viewConfig } %}
-      {% endif %}
-    </div>
-
-    {# timeline view #}
-    {% if getConfig('crowi', 'customize:isEnabledTimeline') %}
-      <div class="tab-pane mt-5" id="view-timeline">
-        <script type="text/template" id="page-timeline-data">{{ JSON.stringify(pagesDataForTimeline(pages)) | preventXss }}</script>
-        {# render React Component PageTimeline #}
-        <div id="page-timeline"></div>
-      </div>
-    {% endif %}
-  </div>
-</div>

+ 0 - 21
src/server/views/widget/page_tabs.html

@@ -47,27 +47,6 @@
   {# to place right side #}
   {# to place right side #}
   <div class="mr-auto"></div>
   <div class="mr-auto"></div>
 
 
-  <!-- presentation -->
-  {% if not page.isTopPage() %}
-    <li class="nav-item d-edit-none">
-      <a href="?presentation=1&revisionId={{revision.id}}" class="nav-link toggle-presentation">
-        <i class="icon-film icon-fw"></i><span class="d-none d-sm-inline">{{ t('Presentation Mode') }}</span>
-      </a>
-    </li>
-  {% endif %}
-
-  <!-- revision-history -->
-  <li class="nav-item d-edit-none">
-    <a class="nav-link" href="#revision-history" role="tab" data-toggle="tab">
-      <i class="icon-layers icon-fw"></i><span class="d-none d-md-inline">{{ t('History') }}</span>
-    </a>
-  </li>
-
-  <!-- Outside-share-link -->
-  {% if !isTrashPage() %}
-    <li id="page-share-management" class="nav-item dropdown d-edit-none"></li>
-  {% endif %}
-
   <!-- icon-options-vertical -->
   <!-- icon-options-vertical -->
   {% if !isTrashPage() %}
   {% if !isTrashPage() %}
     <li id="page-management" class="nav-item dropdown d-edit-none"></li>
     <li id="page-management" class="nav-item dropdown d-edit-none"></li>

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

@@ -32,7 +32,7 @@
 
 
     <div class="tab-pane user-bookmark-list page-list active" id="user-bookmark-list">
     <div class="tab-pane user-bookmark-list page-list active" id="user-bookmark-list">
       {% if bookmarkList.length == 0 %}
       {% if bookmarkList.length == 0 %}
-        No bookmarks yet.
+        {{t('No bookmarks yet')}}.
       {% else %}
       {% else %}
         <div class="page-list-container">
         <div class="page-list-container">
           {% include 'page_list.html' with { pages: bookmarkList, pagePropertyName: 'page' } %}
           {% include 'page_list.html' with { pages: bookmarkList, pagePropertyName: 'page' } %}