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

Merge branch 'feat/80324-adjust-design-for-left-pane' into feat/80324-82172-selected-page-design

* feat/80324-adjust-design-for-left-pane: (134 commits)
  change type
  82578 change margin
  82578 show nothing when result count is 0
  82578 render showing page count infomation
  77545 add conma
  81845 fix lint error
  81664 rename
  81664 rename
  81761 search control class
  81761 apply theme in search control
  81664 fix margin
  81664 rename
  81845 fix name
  81664 change file name
  81664 rename
  81664 fix display word
  81664 rename method
  81845 rename method
  81110 remove unnecessary code
  81110 fix lint
  ...
Mao 4 лет назад
Родитель
Сommit
01f75f7822
34 измененных файлов с 780 добавлено и 443 удалено
  1. 1 0
      packages/app/resource/locales/en_US/translation.json
  2. 2 1
      packages/app/resource/locales/ja_JP/translation.json
  3. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  4. 0 16
      packages/app/src/client/services/PageContainer.js
  5. 12 23
      packages/app/src/components/BookmarkButton.jsx
  6. 3 11
      packages/app/src/components/ComparePathsTable.jsx
  7. 3 13
      packages/app/src/components/CreateTemplateModal.jsx
  8. 0 102
      packages/app/src/components/LikeButtons.jsx
  9. 81 0
      packages/app/src/components/LikeButtons.tsx
  10. 57 64
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  11. 0 80
      packages/app/src/components/Navbar/SubNavButtons.jsx
  12. 119 0
      packages/app/src/components/Navbar/SubNavButtons.tsx
  13. 19 13
      packages/app/src/components/Page/PageManagement.jsx
  14. 7 4
      packages/app/src/components/Page/RevisionLoader.jsx
  15. 9 48
      packages/app/src/components/Page/TagLabels.jsx
  16. 8 2
      packages/app/src/components/PageDeleteModal.jsx
  17. 1 1
      packages/app/src/components/PageDuplicateModal.jsx
  18. 56 0
      packages/app/src/components/PagePathNav.tsx
  19. 41 0
      packages/app/src/components/PageReactionButtons.tsx
  20. 1 1
      packages/app/src/components/PageRenameModal.jsx
  21. 19 14
      packages/app/src/components/SearchPage.jsx
  22. 60 16
      packages/app/src/components/SearchPage/SearchControl.tsx
  23. 83 0
      packages/app/src/components/SearchPage/SearchOptionModal.tsx
  24. 18 10
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  25. 15 18
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  26. 91 0
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  27. 4 0
      packages/app/src/interfaces/bookmark-info.ts
  28. 8 0
      packages/app/src/interfaces/page-info.ts
  29. 3 0
      packages/app/src/interfaces/pageTagsInfo.ts
  30. 2 2
      packages/app/src/server/models/page.js
  31. 16 0
      packages/app/src/stores/bookmark.ts
  32. 27 4
      packages/app/src/stores/page.tsx
  33. 10 0
      packages/app/src/stores/user.tsx
  34. 3 0
      packages/app/src/styles/theme/_apply-colors.scss

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -577,6 +577,7 @@
     "delete_completely": "Delete completely",
     "delete_completely": "Delete completely",
     "include_certain_path" : "Include {{pathToInclude}} path ",
     "include_certain_path" : "Include {{pathToInclude}} path ",
     "delete_all_selected_page" : "Delete All",
     "delete_all_selected_page" : "Delete All",
+    "search_again" : "Search again",
     "number_of_list_to_display" : "Display",
     "number_of_list_to_display" : "Display",
     "page_number_unit" : "pages"
     "page_number_unit" : "pages"
 
 

+ 2 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -64,7 +64,7 @@
   "Include Attachment File": "添付ファイルも含める",
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
   "Include Subordinated Page": "配下ページも含める",
-  "Include Subordinated Target Page": "{{target}} 下含む",
+  "Include Subordinated Target Page": "{{target}} 下含む",
   "All Subordinated Page": "全ての配下ページ",
   "All Subordinated Page": "全ての配下ページ",
   "Specify Hierarchy": "階層の深さを指定",
   "Specify Hierarchy": "階層の深さを指定",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
@@ -577,6 +577,7 @@
     "delete_completely": "完全に削除する",
     "delete_completely": "完全に削除する",
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "delete_all_selected_page" : "一括削除",
     "delete_all_selected_page" : "一括削除",
+    "search_again" : "再検索",
     "number_of_list_to_display" : "表示件数",
     "number_of_list_to_display" : "表示件数",
     "page_number_unit" : "件"
     "page_number_unit" : "件"
   },
   },

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -850,6 +850,7 @@
 		"delete_completely": "完全删除",
 		"delete_completely": "完全删除",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "delete_all_selected_page": "删除所有",
     "delete_all_selected_page": "删除所有",
+    "search_again" : "再次搜索",
     "number_of_list_to_display" : "显示器的数量",
     "number_of_list_to_display" : "显示器的数量",
     "page_number_unit" : "例"
     "page_number_unit" : "例"
 	},
 	},

+ 0 - 16
packages/app/src/client/services/PageContainer.js

@@ -292,22 +292,6 @@ export default class PageContainer extends Container {
     await this.retrieveLikersAndSeenUsers();
     await this.retrieveLikersAndSeenUsers();
   }
   }
 
 
-  async toggleLike() {
-    {
-      const toggledIsLiked = !this.state.isLiked;
-      await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool: toggledIsLiked });
-
-      await this.setState(state => ({
-        isLiked: toggledIsLiked,
-        sumOfLikers: toggledIsLiked ? state.sumOfLikers + 1 : state.sumOfLikers - 1,
-        likerIds: toggledIsLiked
-          ? [...this.state.likerIds, this.appContainer.currentUserId]
-          : state.likerIds.filter(id => id !== this.appContainer.currentUserId),
-      }));
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
 
 
   async retrieveLikersAndSeenUsers() {
   async retrieveLikersAndSeenUsers() {
     const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
     const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });

+ 12 - 23
packages/app/src/components/BookmarkButton.jsx

@@ -10,7 +10,7 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
-class BookmarkButton extends React.Component {
+class LegacyBookmarkButton extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -19,25 +19,11 @@ class BookmarkButton extends React.Component {
   }
   }
 
 
   async handleClick() {
   async handleClick() {
-    const {
-      appContainer, pageId, isBookmarked, onChangeInvoked,
-    } = this.props;
-    const { isGuestUser } = appContainer;
 
 
-    if (isGuestUser) {
+    if (this.props.onBookMarkClicked == null) {
       return;
       return;
     }
     }
-
-    try {
-      const bool = !isBookmarked;
-      await apiv3Put('/bookmarks', { pageId, bool });
-      if (onChangeInvoked != null) {
-        onChangeInvoked();
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
+    this.props.onBookMarkClicked();
   }
   }
 
 
   render() {
   render() {
@@ -77,21 +63,24 @@ class BookmarkButton extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer]);
+const LegacyBookmarkButtonWrapper = withUnstatedContainers(LegacyBookmarkButton, [AppContainer]);
 
 
-BookmarkButton.propTypes = {
+LegacyBookmarkButton.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
-  pageId: PropTypes.string.isRequired,
   isBookmarked: PropTypes.bool.isRequired,
   isBookmarked: PropTypes.bool.isRequired,
   sumOfBookmarks: PropTypes.number,
   sumOfBookmarks: PropTypes.number,
-  onChangeInvoked: PropTypes.func,
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
   size: PropTypes.string,
   size: PropTypes.string,
+  onBookMarkClicked: PropTypes.func,
 };
 };
 
 
-BookmarkButton.defaultProps = {
+LegacyBookmarkButton.defaultProps = {
   size: 'md',
   size: 'md',
 };
 };
 
 
-export default withTranslation()(BookmarkButtonWrapper);
+const BookmarkButton = (props) => {
+  return <LegacyBookmarkButtonWrapper {...props}></LegacyBookmarkButtonWrapper>;
+};
+
+export default withTranslation()(BookmarkButton);

+ 3 - 11
packages/app/src/components/ComparePathsTable.jsx

@@ -3,17 +3,14 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
-import PageContainer from '~/client/services/PageContainer';
 
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 
 function ComparePathsTable(props) {
 function ComparePathsTable(props) {
   const {
   const {
-    subordinatedPages, pageContainer, newPagePath, t,
+    path, subordinatedPages, newPagePath, t,
   } = props;
   } = props;
-  const { path } = pageContainer.state;
 
 
   return (
   return (
     <table className="table table-bordered grw-compare-paths-table">
     <table className="table table-bordered grw-compare-paths-table">
@@ -45,18 +42,13 @@ function ComparePathsTable(props) {
 }
 }
 
 
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(ComparePathsTable, [PageContainer]);
-
 ComparePathsTable.propTypes = {
 ComparePathsTable.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
 
 
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  path: PropTypes.string.isRequired,
   subordinatedPages: PropTypes.array.isRequired,
   subordinatedPages: PropTypes.array.isRequired,
   newPagePath: PropTypes.string.isRequired,
   newPagePath: PropTypes.string.isRequired,
 };
 };
 
 
 
 
-export default withTranslation()(PageDuplicateModallWrapper);
+export default withTranslation()(ComparePathsTable);

+ 3 - 13
packages/app/src/components/CreateTemplateModal.jsx

@@ -6,14 +6,11 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
-import PageContainer from '~/client/services/PageContainer';
 
 
 const CreateTemplateModal = (props) => {
 const CreateTemplateModal = (props) => {
-  const { t, pageContainer } = props;
+  const { t, path } = props;
 
 
-  const { path } = pageContainer.state;
   const parentPath = pathUtils.addTrailingSlash(path);
   const parentPath = pathUtils.addTrailingSlash(path);
 
 
   function generateUrl(label) {
   function generateUrl(label) {
@@ -67,18 +64,11 @@ const CreateTemplateModal = (props) => {
 };
 };
 
 
 
 
-/**
- * Wrapper component for using unstated
- */
-const CreateTemplateModalWrapper = withUnstatedContainers(CreateTemplateModal, [PageContainer]);
-
-
 CreateTemplateModal.propTypes = {
 CreateTemplateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(CreateTemplateModalWrapper);
+export default withTranslation()(CreateTemplateModal);

+ 0 - 102
packages/app/src/components/LikeButtons.jsx

@@ -1,102 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import UserPictureList from './User/UserPictureList';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import { toastError } from '~/client/util/apiNotification';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-class LikeButtons extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isPopoverOpen: false,
-    };
-
-    this.togglePopover = this.togglePopover.bind(this);
-    this.handleClick = this.handleClick.bind(this);
-  }
-
-  togglePopover() {
-    this.setState(prevState => ({
-      ...prevState,
-      isPopoverOpen: !prevState.isPopoverOpen,
-    }));
-  }
-
-  async handleClick() {
-    const { appContainer, pageContainer } = this.props;
-    const { isGuestUser } = appContainer;
-
-    if (isGuestUser) {
-      return;
-    }
-
-    try {
-      pageContainer.toggleLike();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { appContainer, pageContainer, t } = this.props;
-    const { isGuestUser } = appContainer;
-    const {
-      state: { likers, sumOfLikers, isLiked },
-    } = pageContainer;
-
-    return (
-      <div className="btn-group" role="group" aria-label="Like buttons">
-        <button
-          type="button"
-          id="like-button"
-          onClick={this.handleClick}
-          className={`btn btn-like border-0
-            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
-        >
-          <i className="icon-like"></i>
-        </button>
-        {isGuestUser && (
-          <UncontrolledTooltip placement="top" target="like-button" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-
-        <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
-          {sumOfLikers}
-        </button>
-        <Popover placement="bottom" isOpen={this.state.isPopoverOpen} target="po-total-likes" toggle={this.togglePopover} trigger="legacy">
-          <PopoverBody className="seen-user-popover">
-            <div className="px-2 text-right user-list-content text-truncate text-muted">
-              {likers.length ? <UserPictureList users={likers} /> : t('No users have liked this yet.')}
-            </div>
-          </PopoverBody>
-        </Popover>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const LikeButtonsWrapper = withUnstatedContainers(LikeButtons, [AppContainer, PageContainer]);
-
-LikeButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  t: PropTypes.func.isRequired,
-  size: PropTypes.string,
-};
-
-export default withTranslation()(LikeButtonsWrapper);

+ 81 - 0
packages/app/src/components/LikeButtons.tsx

@@ -0,0 +1,81 @@
+import React, { FC, useState } from 'react';
+
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import UserPictureList from './User/UserPictureList';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '../interfaces/user';
+
+type LikeButtonsProps = {
+  appContainer: AppContainer,
+  sumOfLikers: number,
+  isLiked: boolean,
+  likers: IUser[],
+  onLikeClicked?: ()=>void,
+  t: (s:string)=>string,
+}
+
+const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const togglePopover = () => {
+    setIsPopoverOpen(!isPopoverOpen);
+  };
+
+
+  const handleClick = () => {
+    if (props.onLikeClicked == null) {
+      return;
+    }
+    props.onLikeClicked();
+  };
+
+  const {
+    appContainer, isLiked, sumOfLikers, t,
+  } = props;
+  const { isGuestUser } = appContainer;
+
+  return (
+    <div className="btn-group" role="group" aria-label="Like buttons">
+      <button
+        type="button"
+        id="like-button"
+        onClick={handleClick}
+        className={`btn btn-like border-0
+            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+      >
+        <i className="icon-like"></i>
+      </button>
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+        {sumOfLikers}
+      </button>
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            {props.likers.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LikeButtonsUnstatedWrapper = withUnstatedContainers(LikeButtons, [AppContainer]);
+
+const LikeButtonsWrapper = (props) => {
+  return <LikeButtonsUnstatedWrapper {...props}></LikeButtonsUnstatedWrapper>;
+};
+
+export default withTranslation()(LikeButtonsWrapper);

+ 57 - 64
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,89 +1,76 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
-
-import { DevidedPagePath } from '@growi/core';
-import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import LinkedPagePath from '~/models/linked-page-path';
-
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import EditorContainer from '~/client/services/EditorContainer';
 
 
-import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
 import TagLabels from '../Page/TagLabels';
-import SubnavButtons from './SubNavButtons';
+import SubNavButtons from './SubNavButtons';
 import PageEditorModeManager from './PageEditorModeManager';
 import PageEditorModeManager from './PageEditorModeManager';
 
 
 import AuthorInfo from './AuthorInfo';
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 import DrawerToggler from './DrawerToggler';
 
 
-const PagePathNav = ({
-  // eslint-disable-next-line react/prop-types
-  pageId, pagePath, isEditorMode, isCompactMode,
-}) => {
-
-  const dPagePath = new DevidedPagePath(pagePath, false, true);
-
-  let formerLink;
-  let latterLink;
-
-  // one line
-  if (dPagePath.isRoot || dPagePath.isFormerRoot || isEditorMode) {
-    const linkedPagePath = new LinkedPagePath(pagePath);
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-  }
-  // two line
-  else {
-    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
-  }
+import PagePathNav from '../PagePathNav';
 
 
-  const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
-  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0 py-0';
-
-  return (
-    <div className="grw-page-path-nav">
-      {formerLink}
-      <span className="d-flex align-items-center">
-        <h1 className="m-0">{latterLink}</h1>
-        <div className="mx-2">
-          <CopyDropdown
-            pageId={pageId}
-            pagePath={pagePath}
-            dropdownToggleId={copyDropdownId}
-            dropdownToggleClassName={copyDropdownToggleClassName}
-          >
-            <i className="ti-clipboard"></i>
-          </CopyDropdown>
-        </div>
-      </span>
-    </div>
-  );
-};
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
 
 
 const GrowiSubNavigation = (props) => {
 const GrowiSubNavigation = (props) => {
   const {
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, navigationContainer, pageContainer, editorContainer, isCompactMode,
   } = props;
   } = props;
   const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
   const {
-    pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
+    pageId,
+    revisionId,
+    path,
+    isDeletable,
+    isAbleToDeleteCompletely,
+    createdAt,
+    creator,
+    updatedAt,
+    revisionAuthor,
+    isPageExist,
+    isTrashPage,
+    tags,
   } = pageContainer.state;
   } = pageContainer.state;
 
 
-  const { isGuestUser } = appContainer;
+  const { isGuestUser, isSharedUser } = appContainer;
   const isEditorMode = editorMode !== 'view';
   const isEditorMode = editorMode !== 'view';
   // Tags cannot be edited while the new page and editorMode is view
   // Tags cannot be edited while the new page and editorMode is view
   const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
   const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
 
 
+  const isAbleToShowPageManagement = isPageExist && !isTrashPage && !isSharedUser;
   function onPageEditorModeButtonClicked(viewType) {
   function onPageEditorModeButtonClicked(viewType) {
     navigationContainer.setEditorMode(viewType);
     navigationContainer.setEditorMode(viewType);
   }
   }
 
 
+  const tagsUpdatedHandler = useCallback(async(newTags) => {
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === 'edit') {
+      return editorContainer.setState({ tags: newTags });
+    }
+
+    try {
+      const { tags } = await apiPost('/tags.update', { pageId, tags: newTags });
+
+      // update pageContainer.state
+      pageContainer.setState({ tags });
+      // update editorContainer.state
+      editorContainer.setState({ tags });
+
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pageId]);
+
   return (
   return (
     <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
     <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
 
 
@@ -98,10 +85,10 @@ const GrowiSubNavigation = (props) => {
         <div className="grw-path-nav-container">
         <div className="grw-path-nav-container">
           { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
           { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
             <div className="grw-taglabels-container">
             <div className="grw-taglabels-container">
-              <TagLabels editorMode={editorMode} />
+              <TagLabels tags={tags} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
             </div>
           ) }
           ) }
-          <PagePathNav pageId={pageId} pagePath={path} isEditorMode={isEditorMode} isCompactMode={isCompactMode} />
+          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
         </div>
         </div>
       </div>
       </div>
 
 
@@ -110,7 +97,15 @@ const GrowiSubNavigation = (props) => {
 
 
         <div className="d-flex flex-column align-items-end">
         <div className="d-flex flex-column align-items-end">
           <div className="d-flex">
           <div className="d-flex">
-            <SubnavButtons isCompactMode={isCompactMode} />
+            <SubNavButtons
+              isCompactMode={isCompactMode}
+              pageId={pageId}
+              revisionId={revisionId}
+              path={path}
+              isDeletable={isDeletable}
+              isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+              willShowPageManagement={isAbleToShowPageManagement}
+            />
           </div>
           </div>
           <div className="mt-2">
           <div className="mt-2">
             {pageContainer.isAbleToShowPageEditorModeManager && (
             {pageContainer.isAbleToShowPageEditorModeManager && (
@@ -136,25 +131,23 @@ const GrowiSubNavigation = (props) => {
           </ul>
           </ul>
         ) }
         ) }
       </div>
       </div>
-
     </div>
     </div>
   );
   );
-
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer, EditorContainer]);
 
 
 
 
 GrowiSubNavigation.propTypes = {
 GrowiSubNavigation.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,
 };
 };
 
 
-export default withTranslation()(GrowiSubNavigationWrapper);
+export default GrowiSubNavigationWrapper;

+ 0 - 80
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -1,80 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import loggerFactory from '~/utils/logger';
-
-import BookmarkButton from '../BookmarkButton';
-import LikeButtons from '../LikeButtons';
-import PageManagement from '../Page/PageManagement';
-
-const logger = loggerFactory('growi:SubnavButtons');
-
-const SubnavButtons = (props) => {
-  const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
-  } = props;
-
-  /* eslint-enable react/prop-types */
-
-  /* eslint-disable react/prop-types */
-  const PageReactionButtons = ({ pageContainer }) => {
-    const { pageId, isBookmarked, sumOfBookmarks } = pageContainer.state;
-
-    const onChangeInvoked = () => {
-      if (pageContainer.retrieveBookmarkInfo == null) { logger.error('retrieveBookmarkInfo is null') }
-      else { pageContainer.retrieveBookmarkInfo() }
-    };
-
-    return (
-      <>
-        {pageContainer.isAbleToShowLikeButtons && (
-          <span>
-            <LikeButtons />
-          </span>
-        )}
-        <span>
-          <BookmarkButton
-            pageId={pageId}
-            isBookmarked={isBookmarked}
-            sumOfBookmarks={sumOfBookmarks}
-            onChangeInvoked={onChangeInvoked}
-          />
-        </span>
-      </>
-    );
-  };
-  /* eslint-enable react/prop-types */
-
-  const { editorMode } = navigationContainer.state;
-  const isViewMode = editorMode === 'view';
-
-  return (
-    <>
-      {isViewMode && (
-        <>
-          {pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} />}
-          {pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} />}
-        </>
-      )}
-    </>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, NavigationContainer, PageContainer]);
-
-
-SubnavButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isCompactMode: PropTypes.bool,
-};
-
-export default SubnavButtonsWrapper;

+ 119 - 0
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -0,0 +1,119 @@
+import React, {
+  FC, useCallback, useState, useEffect,
+} from 'react';
+import AppContainer from '../../client/services/AppContainer';
+import NavigationContainer from '../../client/services/NavigationContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import PageReactionButtons from '../PageReactionButtons';
+import PageManagement from '../Page/PageManagement';
+import { useSWRPageInfo } from '../../stores/page';
+import { useSWRBookmarkInfo } from '../../stores/bookmark';
+import { toastError } from '../../client/util/apiNotification';
+import { apiv3Put } from '../../client/util/apiv3-client';
+import { useSWRxLikerList } from '../../stores/user';
+
+type SubNavButtonsProps= {
+  appContainer: AppContainer,
+  navigationContainer: NavigationContainer,
+  isCompactMode?: boolean,
+  pageId: string,
+  revisionId: string,
+  path: string,
+  willShowPageManagement: boolean,
+  isDeletable: boolean,
+  isAbleToDeleteCompletely: boolean,
+}
+const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
+  const {
+    appContainer, navigationContainer, isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+  } = props;
+  const { editorMode } = navigationContainer.state;
+  const isViewMode = editorMode === 'view';
+  const { isGuestUser } = appContainer;
+
+  const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
+  const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
+  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+
+  const likeClickhandler = useCallback(async() => {
+    const { isGuestUser } = appContainer;
+    if (isGuestUser) {
+      return;
+    }
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      await apiv3Put('/page/likes', { pageId, bool: !pageInfo!.isLiked });
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [pageInfo]);
+
+  const bookmarkClickHandler = useCallback(async() => {
+    if (isGuestUser) {
+      return;
+    }
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      await apiv3Put('/bookmarks', { pageId, bool: !bookmarkInfo!.isBookmarked });
+      mutateBookmarkInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkInfo]);
+
+  if (pageInfoError != null || pageInfo == null) {
+    return <></>;
+  }
+
+  if (bookmarkInfoError != null || bookmarkInfo == null) {
+    return <></>;
+  }
+
+  const { sumOfLikers, isLiked } = pageInfo;
+  const { sumOfBookmarks, isBookmarked } = bookmarkInfo;
+
+  return (
+    <>
+      {isViewMode && (
+        <PageReactionButtons
+          sumOfLikers={sumOfLikers}
+          isLiked={isLiked}
+          likers={likers || []}
+          onLikeClicked={likeClickhandler}
+          sumOfBookmarks={sumOfBookmarks}
+          isBookmarked={isBookmarked}
+          onBookMarkClicked={bookmarkClickHandler}
+        >
+        </PageReactionButtons>
+      )}
+      {willShowPageManagement && (
+        <PageManagement
+          pageId={pageId}
+          revisionId={revisionId}
+          path={path}
+          isCompactMode={isCompactMode}
+          isDeletable={isDeletable}
+          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+        >
+        </PageManagement>
+      )}
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SubNavButtonsUnstatedWrapper = withUnstatedContainers(SubNavButtons, [AppContainer, NavigationContainer]);
+
+// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
+const SubNavButtonsWrapper = (props) => {
+  return <SubNavButtonsUnstatedWrapper {...props}></SubNavButtonsUnstatedWrapper>;
+};
+
+export default SubNavButtonsWrapper;

+ 19 - 13
packages/app/src/components/Page/PageManagement.jsx

@@ -7,7 +7,6 @@ import urljoin from 'url-join';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import PageDeleteModal from '../PageDeleteModal';
 import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import PageDuplicateModal from '../PageDuplicateModal';
@@ -18,13 +17,10 @@ import PresentationIcon from '../Icons/PresentationIcon';
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
 
 
-const PageManagement = (props) => {
+const LegacyPageManagemenet = (props) => {
   const {
   const {
-    t, appContainer, pageContainer, isCompactMode,
+    t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
   } = props;
   } = props;
-  const {
-    pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
-  } = pageContainer.state;
 
 
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
   const isTopPagePath = isTopPage(path);
@@ -33,6 +29,7 @@ const PageManagement = (props) => {
   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);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
+  const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
 
 
   function openPageRenameModalHandler() {
   function openPageRenameModalHandler() {
     setIsPageRenameModalShown(true);
     setIsPageRenameModalShown(true);
@@ -86,7 +83,6 @@ const PageManagement = (props) => {
   // }
   // }
 
 
   async function exportPageHandler(format) {
   async function exportPageHandler(format) {
-    const { pageId, revisionId } = pageContainer.state;
     const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
     const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
     url.searchParams.append('format', format);
     url.searchParams.append('format', format);
     url.searchParams.append('revisionId', revisionId);
     url.searchParams.append('revisionId', revisionId);
@@ -178,6 +174,7 @@ const PageManagement = (props) => {
           path={path}
           path={path}
         />
         />
         <CreateTemplateModal
         <CreateTemplateModal
+          path={path}
           isOpen={isPageTemplateModalShown}
           isOpen={isPageTemplateModalShown}
           onClose={closePageTemplateModalHandler}
           onClose={closePageTemplateModalHandler}
         />
         />
@@ -192,7 +189,7 @@ const PageManagement = (props) => {
         <PagePresentationModal
         <PagePresentationModal
           isOpen={isPagePresentationModalShown}
           isOpen={isPagePresentationModalShown}
           onClose={closePagePresentationModalHandler}
           onClose={closePagePresentationModalHandler}
-          href="?presentation=1"
+          href={presentationHref}
         />
         />
       </>
       </>
     );
     );
@@ -248,19 +245,28 @@ const PageManagement = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageManagementWrapper = withUnstatedContainers(PageManagement, [AppContainer, PageContainer]);
+const LegacyPageManagemenetWrapper = withUnstatedContainers(LegacyPageManagemenet, [AppContainer]);
 
 
 
 
-PageManagement.propTypes = {
+LegacyPageManagemenet.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,
+
+
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  path: PropTypes.string.isRequired,
+  isDeletable: PropTypes.bool.isRequired,
+  isAbleToDeleteCompletely: PropTypes.bool.isRequired,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,
 };
 };
 
 
-PageManagement.defaultProps = {
+LegacyPageManagemenet.defaultProps = {
   isCompactMode: false,
   isCompactMode: false,
 };
 };
 
 
-export default withTranslation()(PageManagementWrapper);
+const PageManagement = (props) => {
+  return <LegacyPageManagemenetWrapper {...props}></LegacyPageManagemenetWrapper>;
+};
+export default withTranslation()(PageManagement);

+ 7 - 4
packages/app/src/components/Page/RevisionLoader.jsx

@@ -13,7 +13,7 @@ import RevisionRenderer from './RevisionRenderer';
 /**
 /**
  * Load data from server and render RevisionBody component
  * Load data from server and render RevisionBody component
  */
  */
-class RevisionLoader extends React.Component {
+class LegacyRevisionLoader extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -116,9 +116,9 @@ class RevisionLoader extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const RevisionLoaderWrapper = withUnstatedContainers(RevisionLoader, [AppContainer]);
+const LegacyRevisionLoaderWrapper = withUnstatedContainers(LegacyRevisionLoader, [AppContainer]);
 
 
-RevisionLoader.propTypes = {
+LegacyRevisionLoader.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
@@ -129,4 +129,7 @@ RevisionLoader.propTypes = {
   highlightKeywords: PropTypes.string,
   highlightKeywords: PropTypes.string,
 };
 };
 
 
-export default RevisionLoaderWrapper;
+const RevisionLoader = (props) => {
+  return <LegacyRevisionLoaderWrapper {...props}></LegacyRevisionLoaderWrapper>;
+};
+export default RevisionLoader;

+ 9 - 48
packages/app/src/components/Page/TagLabels.jsx

@@ -2,12 +2,9 @@ import React, { Suspense } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 
 
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 import TagEditModal from './TagEditModal';
@@ -23,18 +20,8 @@ class TagLabels extends React.Component {
 
 
     this.openEditorModal = this.openEditorModal.bind(this);
     this.openEditorModal = this.openEditorModal.bind(this);
     this.closeEditorModal = this.closeEditorModal.bind(this);
     this.closeEditorModal = this.closeEditorModal.bind(this);
-    this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
   }
   }
 
 
-  /**
-   * @return tags data
-   *   1. pageContainer.state.tags if editorMode is view
-   *   2. editorContainer.state.tags if editorMode is edit
-   */
-  getTagData() {
-    const { editorContainer, pageContainer, editorMode } = this.props;
-    return (editorMode === 'edit') ? editorContainer.state.tags : pageContainer.state.tags;
-  }
 
 
   openEditorModal() {
   openEditorModal() {
     this.setState({ isTagEditModalShown: true });
     this.setState({ isTagEditModalShown: true });
@@ -44,37 +31,9 @@ class TagLabels extends React.Component {
     this.setState({ isTagEditModalShown: false });
     this.setState({ isTagEditModalShown: false });
   }
   }
 
 
-  async tagsUpdatedHandler(newTags) {
-    const {
-      appContainer, editorContainer, pageContainer, editorMode,
-    } = this.props;
-
-    const { pageId } = pageContainer.state;
-
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === 'edit') {
-      return editorContainer.setState({ tags: newTags });
-    }
-
-    try {
-      const { tags } = await appContainer.apiPost('/tags.update', { pageId, tags: newTags });
-
-      // update pageContainer.state
-      pageContainer.setState({ tags });
-      // update editorContainer.state
-      editorContainer.setState({ tags });
-
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  }
-
 
 
   render() {
   render() {
-    const tags = this.getTagData();
-    const { appContainer } = this.props;
+    const { appContainer, tagsUpdateInvoked, tags } = this.props;
 
 
     return (
     return (
       <>
       <>
@@ -95,7 +54,7 @@ class TagLabels extends React.Component {
           isOpen={this.state.isTagEditModalShown}
           isOpen={this.state.isTagEditModalShown}
           onClose={this.closeEditorModal}
           onClose={this.closeEditorModal}
           appContainer={this.props.appContainer}
           appContainer={this.props.appContainer}
-          onTagsUpdated={this.tagsUpdatedHandler}
+          onTagsUpdated={tagsUpdateInvoked}
         />
         />
 
 
       </>
       </>
@@ -107,16 +66,18 @@ class TagLabels extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, PageContainer, EditorContainer]);
+const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer]);
 
 
 TagLabels.propTypes = {
 TagLabels.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,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  editorMode: PropTypes.string.isRequired,
+  tags: PropTypes.arrayOf(PropTypes.object).isRequired,
+  tagsUpdateInvoked: PropTypes.func,
 };
 };
 
 
+// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
+const TagLabelsWrapper = (props) => {
+  return <TagLabelsUnstatedWrapper {...props}></TagLabelsUnstatedWrapper>;
+};
 export default withTranslation()(TagLabelsWrapper);
 export default withTranslation()(TagLabelsWrapper);

+ 8 - 2
packages/app/src/components/PageDeleteModal.jsx

@@ -91,6 +91,12 @@ const PageDeleteModal = (props) => {
     );
     );
   }
   }
 
 
+  // DeleteCompletely is currently disabled
+  // TODO1 : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
+  // Story: https://redmine.weseek.co.jp/issues/82222
+
+  // TODO2 : use toaster
+  // TASK : https://redmine.weseek.co.jp/issues/82299
   function renderDeleteCompletelyForm() {
   function renderDeleteCompletelyForm() {
     return (
     return (
       <div className="custom-control custom-checkbox custom-checkbox-danger">
       <div className="custom-control custom-checkbox custom-checkbox-danger">
@@ -99,12 +105,12 @@ const PageDeleteModal = (props) => {
           name="completely"
           name="completely"
           id="deleteCompletely"
           id="deleteCompletely"
           type="checkbox"
           type="checkbox"
-          disabled={!isAbleToDeleteCompletely}
+          disabled
           checked={isDeleteCompletely}
           checked={isDeleteCompletely}
           onChange={changeIsDeleteCompletelyHandler}
           onChange={changeIsDeleteCompletelyHandler}
         />
         />
         <label className="custom-control-label text-danger" htmlFor="deleteCompletely">
         <label className="custom-control-label text-danger" htmlFor="deleteCompletely">
-          { t('modal_delete.delete_completely') }
+          { t('modal_delete.delete_completely')}
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
         </label>
         </label>
         {!isAbleToDeleteCompletely
         {!isAbleToDeleteCompletely

+ 1 - 1
packages/app/src/components/PageDuplicateModal.jsx

@@ -188,7 +188,7 @@ const PageDuplicateModal = (props) => {
             )}
             )}
           </div>
           </div>
           <div>
           <div>
-            {isDuplicateRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+            {isDuplicateRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           </div>
           </div>
         </div>
         </div>

+ 56 - 0
packages/app/src/components/PagePathNav.tsx

@@ -0,0 +1,56 @@
+import React, { FC } from 'react';
+import { DevidedPagePath } from '@growi/core';
+import PagePathHierarchicalLink from './PagePathHierarchicalLink';
+import CopyDropdown from './Page/CopyDropdown';
+
+import LinkedPagePath from '../models/linked-page-path';
+
+
+type Props = {
+  pageId :string,
+  pagePath:string,
+  isSingleLineMode?:boolean,
+  isCompactMode?:boolean,
+}
+
+const PagePathNav: FC<Props> = (props: Props) => {
+  const {
+    pageId, pagePath, isSingleLineMode, isCompactMode,
+  } = props;
+  const dPagePath = new DevidedPagePath(pagePath, false, true);
+
+  let formerLink;
+  let latterLink;
+
+  // one line
+  if (dPagePath.isRoot || dPagePath.isFormerRoot || isSingleLineMode) {
+    const linkedPagePath = new LinkedPagePath(pagePath);
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
+  }
+  // two line
+  else {
+    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
+  }
+
+  const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
+  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0 py-0';
+
+  return (
+    <div className="grw-page-path-nav">
+      {formerLink}
+      <span className="d-flex align-items-center">
+        <h1 className="m-0">{latterLink}</h1>
+        <div className="mx-2">
+          <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
+            <i className="ti-clipboard"></i>
+          </CopyDropdown>
+        </div>
+      </span>
+    </div>
+  );
+};
+
+export default PagePathNav;

+ 41 - 0
packages/app/src/components/PageReactionButtons.tsx

@@ -0,0 +1,41 @@
+import React, { FC } from 'react';
+import LikeButtons from './LikeButtons';
+import { IUser } from '../interfaces/user';
+import BookmarkButton from './BookmarkButton';
+
+type Props = {
+  sumOfLikers: number,
+  isLiked: boolean,
+  likers: IUser[],
+  onLikeClicked?: ()=>void,
+  sumOfBookmarks: number,
+  isBookmarked: boolean,
+  onBookMarkClicked: ()=>void,
+}
+
+
+const PageReactionButtons : FC<Props> = (props: Props) => {
+  const {
+    sumOfLikers, isLiked, likers, onLikeClicked, sumOfBookmarks, isBookmarked, onBookMarkClicked,
+  } = props;
+
+
+  return (
+    <>
+      <span>
+        <LikeButtons
+          onLikeClicked={onLikeClicked}
+          sumOfLikers={sumOfLikers}
+          isLiked={isLiked}
+          likers={likers}
+        >
+        </LikeButtons>
+      </span>
+      <span>
+        <BookmarkButton sumOfBookmarks={sumOfBookmarks} isBookmarked={isBookmarked} onBookMarkClicked={onBookMarkClicked}></BookmarkButton>
+      </span>
+    </>
+  );
+};
+
+export default PageReactionButtons;

+ 1 - 1
packages/app/src/components/PageRenameModal.jsx

@@ -195,7 +195,7 @@ const PageRenameModal = (props) => {
               </label>
               </label>
             </div>
             </div>
           )}
           )}
-          {isRenameRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
         </div>
 
 

+ 19 - 14
packages/app/src/components/SearchPage.jsx

@@ -33,9 +33,9 @@ class SearchPage extends React.Component {
       selectedPages: new Set(),
       selectedPages: new Set(),
       searchResultCount: 0,
       searchResultCount: 0,
       activePage: 1,
       activePage: 1,
-      pagingLimit: this.props.appContainer.config.pageLimitationL,
-      excludeUsersHome: true,
-      excludeTrash: true,
+      pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
+      excludeUserPages: true,
+      excludeTrashPages: true,
     };
     };
 
 
     this.changeURL = this.changeURL.bind(this);
     this.changeURL = this.changeURL.bind(this);
@@ -43,8 +43,8 @@ class SearchPage extends React.Component {
     this.onSearchInvoked = this.onSearchInvoked.bind(this);
     this.onSearchInvoked = this.onSearchInvoked.bind(this);
     this.selectPage = this.selectPage.bind(this);
     this.selectPage = this.selectPage.bind(this);
     this.toggleCheckBox = this.toggleCheckBox.bind(this);
     this.toggleCheckBox = this.toggleCheckBox.bind(this);
-    this.onExcludeUsersHome = this.onExcludeUsersHome.bind(this);
-    this.onExcludeTrash = this.onExcludeTrash.bind(this);
+    this.switchExcludeUserPagesHandler = this.switchExcludeUserPagesHandler.bind(this);
+    this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
     this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
     this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
     this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
     this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
   }
   }
@@ -68,12 +68,12 @@ class SearchPage extends React.Component {
     return query;
     return query;
   }
   }
 
 
-  onExcludeUsersHome() {
-    this.setState({ excludeUsersHome: !this.state.excludeUsersHome });
+  switchExcludeUserPagesHandler() {
+    this.setState({ excludeUserPages: !this.state.excludeUserPages });
   }
   }
 
 
-  onExcludeTrash() {
-    this.setState({ excludeTrash: !this.state.excludeTrash });
+  switchExcludeTrashPagesHandler() {
+    this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
   }
   }
 
 
   changeURL(keyword, refreshHash) {
   changeURL(keyword, refreshHash) {
@@ -91,10 +91,10 @@ class SearchPage extends React.Component {
     let query = keyword;
     let query = keyword;
 
 
     // pages included in specific path are not retrived when prefix is added
     // pages included in specific path are not retrived when prefix is added
-    if (this.state.excludeTrash) {
+    if (this.state.excludeTrashPages) {
       query = `${query} -prefix:${specificPathNames.trash}`;
       query = `${query} -prefix:${specificPathNames.trash}`;
     }
     }
-    if (this.state.excludeUsersHome) {
+    if (this.state.excludeUserPages) {
       query = `${query} -prefix:${specificPathNames.user}`;
       query = `${query} -prefix:${specificPathNames.user}`;
     }
     }
 
 
@@ -122,6 +122,8 @@ class SearchPage extends React.Component {
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
   }
   }
 
 
+  // todo: refactoring
+  // refs: https://redmine.weseek.co.jp/issues/82139
   async search(data) {
   async search(data) {
     const keyword = data.keyword;
     const keyword = data.keyword;
     if (keyword === '') {
     if (keyword === '') {
@@ -227,8 +229,10 @@ class SearchPage extends React.Component {
         searchingKeyword={this.state.searchingKeyword}
         searchingKeyword={this.state.searchingKeyword}
         appContainer={this.props.appContainer}
         appContainer={this.props.appContainer}
         onSearchInvoked={this.onSearchInvoked}
         onSearchInvoked={this.onSearchInvoked}
-        onExcludeUsersHome={this.onExcludeUsersHome}
-        onExcludeTrash={this.onExcludeTrash}
+        onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
+        onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
+        excludeUserPages={this.state.excludeUserPages}
+        excludeTrashPages={this.state.excludeTrashPages}
       >
       >
       </SearchControl>
       </SearchControl>
     );
     );
@@ -244,7 +248,8 @@ class SearchPage extends React.Component {
           searchResultMeta={this.state.searchResultMeta}
           searchResultMeta={this.state.searchResultMeta}
           searchingKeyword={this.state.searchedKeyword}
           searchingKeyword={this.state.searchedKeyword}
           onPagingLimitChanged={this.onPagingLimitChanged}
           onPagingLimitChanged={this.onPagingLimitChanged}
-          initialPagingLimit={this.props.appContainer.config.pageLimitationL || 50}
+          pagingLimit={this.state.pagingLimit}
+          activePage={this.state.activePage}
         >
         >
         </SearchPageLayout>
         </SearchPageLayout>
       </div>
       </div>

+ 60 - 16
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -1,33 +1,38 @@
-import React, { FC } from 'react';
+import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import SearchPageForm from './SearchPageForm';
 import SearchPageForm from './SearchPageForm';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
 import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
 import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
+import SearchOptionModal from './SearchOptionModal';
 import { CheckboxType } from '../../interfaces/search';
 import { CheckboxType } from '../../interfaces/search';
 
 
 type Props = {
 type Props = {
   searchingKeyword: string,
   searchingKeyword: string,
   appContainer: AppContainer,
   appContainer: AppContainer,
-  onSearchInvoked: (data : any[]) => boolean,
-  onExcludeUsersHome?: () => void,
-  onExcludeTrash?: () => void,
+  excludeUserPages: boolean,
+  excludeTrashPages: boolean,
+  onSearchInvoked: (data: {keyword: string}) => boolean,
+  onExcludeUserPagesSwitched?: () => void,
+  onExcludeTrashPagesSwitched?: () => void,
 }
 }
 
 
 const SearchControl: FC <Props> = (props: Props) => {
 const SearchControl: FC <Props> = (props: Props) => {
+
+  const [isFileterOptionModalShown, setIsFileterOptionModalShown] = useState(false);
   // Temporaly workaround for lint error
   // Temporaly workaround for lint error
   // later needs to be fixed: SearchControl to typescript componet
   // later needs to be fixed: SearchControl to typescript componet
   const SearchPageFormTypeAny : any = SearchPageForm;
   const SearchPageFormTypeAny : any = SearchPageForm;
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
-  const onExcludeUsersHome = () => {
-    if (props.onExcludeUsersHome != null) {
-      props.onExcludeUsersHome();
+  const switchExcludeUserPagesHandler = () => {
+    if (props.onExcludeUserPagesSwitched != null) {
+      props.onExcludeUserPagesSwitched();
     }
     }
   };
   };
 
 
-  const onExcludeTrash = () => {
-    if (props.onExcludeTrash != null) {
-      props.onExcludeTrash();
+  const switchExcludeTrashPagesHandler = () => {
+    if (props.onExcludeTrashPagesSwitched != null) {
+      props.onExcludeTrashPagesSwitched();
     }
     }
   };
   };
 
 
@@ -46,8 +51,36 @@ const SearchControl: FC <Props> = (props: Props) => {
     // ref: https://getbootstrap.com/docs/4.5/components/forms/#checkboxes
     // ref: https://getbootstrap.com/docs/4.5/components/forms/#checkboxes
   };
   };
 
 
+  const openSearchOptionModalHandler = () => {
+    setIsFileterOptionModalShown(true);
+  };
+
+  const closeSearchOptionModalHandler = () => {
+    setIsFileterOptionModalShown(false);
+  };
+
+  const onRetrySearchInvoked = () => {
+    if (props.onSearchInvoked != null) {
+      props.onSearchInvoked({ keyword: props.searchingKeyword });
+    }
+  };
+
+  const rednerSearchOptionModal = () => {
+    return (
+      <SearchOptionModal
+        isOpen={isFileterOptionModalShown || false}
+        onClickFilteringSearchResult={onRetrySearchInvoked}
+        onClose={closeSearchOptionModalHandler}
+        onExcludeUserPagesSwitched={switchExcludeUserPagesHandler}
+        onExcludeTrashPagesSwitched={switchExcludeTrashPagesHandler}
+        excludeUserPages={props.excludeUserPages}
+        excludeTrashPages={props.excludeTrashPages}
+      />
+    );
+  };
+
   return (
   return (
-    <>
+    <div className="position-sticky fixed-top">
       <div className="search-page-nav d-flex py-3 align-items-center">
       <div className="search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
         <div className="flex-grow-1 mx-4">
           <SearchPageFormTypeAny
           <SearchPageFormTypeAny
@@ -62,7 +95,7 @@ const SearchControl: FC <Props> = (props: Props) => {
         </div>
         </div>
       </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
-      <div className="d-flex align-items-center py-3 border-bottom border-gray">
+      <div className="search-control d-flex align-items-center py-2 border-bottom border-gray">
         <div className="d-flex mr-auto ml-4">
         <div className="d-flex mr-auto ml-4">
           {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
           {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
           <DeleteSelectedPageGroup
           <DeleteSelectedPageGroup
@@ -71,14 +104,24 @@ const SearchControl: FC <Props> = (props: Props) => {
             onCheckInvoked={onCheckAllPagesInvoked}
             onCheckInvoked={onCheckAllPagesInvoked}
           />
           />
         </div>
         </div>
-        <div className="d-flex align-items-center mr-3">
+        {/** filter option */}
+        <div className="d-lg-none mr-4">
+          <button
+            type="button"
+            className="btn"
+            onClick={openSearchOptionModalHandler}
+          >
+            <i className="icon-equalizer"></i>
+          </button>
+        </div>
+        <div className="d-none d-lg-flex align-items-center mr-3">
           <div className="border border-gray mr-3">
           <div className="border border-gray mr-3">
             <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
             <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
               <input
               <input
                 className="mr-2"
                 className="mr-2"
                 type="checkbox"
                 type="checkbox"
                 id="flexCheckDefault"
                 id="flexCheckDefault"
-                onClick={() => onExcludeUsersHome()}
+                onClick={switchExcludeUserPagesHandler}
               />
               />
               {t('Include Subordinated Target Page', { target: '/user' })}
               {t('Include Subordinated Target Page', { target: '/user' })}
             </label>
             </label>
@@ -89,14 +132,15 @@ const SearchControl: FC <Props> = (props: Props) => {
                 className="mr-2"
                 className="mr-2"
                 type="checkbox"
                 type="checkbox"
                 id="flexCheckChecked"
                 id="flexCheckChecked"
-                onClick={() => onExcludeTrash()}
+                onClick={switchExcludeTrashPagesHandler}
               />
               />
               {t('Include Subordinated Target Page', { target: '/trash' })}
               {t('Include Subordinated Target Page', { target: '/trash' })}
             </label>
             </label>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-    </>
+      {rednerSearchOptionModal()}
+    </div>
   );
   );
 };
 };
 
 

+ 83 - 0
packages/app/src/components/SearchPage/SearchOptionModal.tsx

@@ -0,0 +1,83 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+
+type Props = {
+  isOpen: boolean,
+  excludeUserPages: boolean,
+  excludeTrashPages: boolean,
+  onClose?: () => void,
+  onExcludeUserPagesSwitched?: () => void,
+  onExcludeTrashPagesSwitched?: () => void,
+  onClickFilteringSearchResult?: () => void,
+}
+
+const SearchOptionModal: FC<Props> = (props: Props) => {
+
+  const { t } = useTranslation('');
+
+  const {
+    isOpen, onClose, excludeUserPages, excludeTrashPages,
+  } = props;
+
+  const onCloseModal = () => {
+    if (onClose != null) {
+      onClose();
+    }
+  };
+
+  const onClickFilteringSearchResult = () => {
+    if (props.onClickFilteringSearchResult != null) {
+      props.onClickFilteringSearchResult();
+      onCloseModal();
+    }
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={onCloseModal} autoFocus={false}>
+      <ModalHeader tag="h4" toggle={onCloseModal} className="bg-primary text-light">
+        Search Option
+      </ModalHeader>
+      <ModalBody>
+        <div className="d-flex p-3">
+          <div className="border border-gray mr-3">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center">
+              <input
+                className="mr-2"
+                type="checkbox"
+                onClick={props.onExcludeUserPagesSwitched}
+                checked={!excludeUserPages}
+              />
+              {t('Include Subordinated Target Page', { target: '/user' })}
+            </label>
+          </div>
+          <div className="border border-gray">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center">
+              <input
+                className="mr-2"
+                type="checkbox"
+                onClick={props.onExcludeTrashPagesSwitched}
+                checked={!excludeTrashPages}
+              />
+              {t('Include Subordinated Target Page', { target: '/trash' })}
+            </label>
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-secondary"
+          onClick={onClickFilteringSearchResult}
+        >{t('search_result.search_again')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default SearchOptionModal;

+ 18 - 10
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -2,9 +2,9 @@ import React, { FC } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 type SearchResultMeta = {
 type SearchResultMeta = {
-  took : number,
-  total : number,
-  results: number
+  took?: number,
+  total?: number,
+  results?: number
 }
 }
 
 
 type Props = {
 type Props = {
@@ -13,36 +13,44 @@ type Props = {
   SearchResultContent: React.FunctionComponent,
   SearchResultContent: React.FunctionComponent,
   searchResultMeta: SearchResultMeta,
   searchResultMeta: SearchResultMeta,
   searchingKeyword: string,
   searchingKeyword: string,
-  initialPagingLimit: number,
+  pagingLimit: number,
+  activePage: number,
   onPagingLimitChanged: (limit: number) => void
   onPagingLimitChanged: (limit: number) => void
 }
 }
 
 
 const SearchPageLayout: FC<Props> = (props: Props) => {
 const SearchPageLayout: FC<Props> = (props: Props) => {
   const { t } = useTranslation('');
   const { t } = useTranslation('');
   const {
   const {
-    SearchResultList, SearchControl, SearchResultContent, searchResultMeta, searchingKeyword,
+    SearchResultList, SearchControl, SearchResultContent, searchResultMeta, searchingKeyword, pagingLimit, activePage,
   } = props;
   } = props;
 
 
+  const renderShowingPageCountInfo = () => {
+    if (searchResultMeta.total == null || searchResultMeta.total === 0) return;
+    const leftNum = pagingLimit * (activePage - 1) + 1;
+    const rightNum = (leftNum - 1) + (searchResultMeta.results || 0);
+    return <span className="ml-3">{`${leftNum}-${rightNum}`} / {searchResultMeta.total || 0}</span>;
+  };
+
   return (
   return (
     <div className="content-main">
     <div className="content-main">
       <div className="search-result d-flex" id="search-result">
       <div className="search-result d-flex" id="search-result">
         <div className="flex-grow-1 flex-basis-0 page-list border boder-gray search-result-list" id="search-result-list">
         <div className="flex-grow-1 flex-basis-0 page-list border boder-gray search-result-list" id="search-result-list">
 
 
-          <nav><SearchControl></SearchControl></nav>
+          <SearchControl></SearchControl>
           <div className="d-flex align-items-center justify-content-between my-3 ml-4">
           <div className="d-flex align-items-center justify-content-between my-3 ml-4">
-            <div className="search-result-meta text-nowrap mr-3">
+            <div className="search-result-meta text-nowrap">
               <span className="font-weight-light">{t('search_result.result_meta')} </span>
               <span className="font-weight-light">{t('search_result.result_meta')} </span>
               <span className="h5">{`"${searchingKeyword}"`}</span>
               <span className="h5">{`"${searchingKeyword}"`}</span>
               {/* Todo: replace "1-10" to the appropriate value */}
               {/* Todo: replace "1-10" to the appropriate value */}
-              <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
+              {renderShowingPageCountInfo()}
             </div>
             </div>
-            <div className="input-group search-result-select-group">
+            <div className="input-group search-result-select-group ml-4">
               <div className="input-group-prepend">
               <div className="input-group-prepend">
                 <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
                 <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
               </div>
               </div>
               <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
               <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
                 {[20, 50, 100, 200].map((limit) => {
                 {[20, 50, 100, 200].map((limit) => {
-                  return <option selected={limit === props.initialPagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                  return <option selected={limit === props.pagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
                 })}
                 })}
               </select>
               </select>
             </div>
             </div>

+ 15 - 18
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -4,35 +4,32 @@ import { IPageSearchResultData } from '../../interfaces/search';
 
 
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
+import SearchResultContentSubNavigation from './SearchResultContentSubNavigation';
 
 
+// TODO : set focusedPage type to ?IPageSearchResultData once #80214 is merged
+// PR: https://github.com/weseek/growi/pull/4649
 
 
 type Props ={
 type Props ={
   appContainer: AppContainer,
   appContainer: AppContainer,
   searchingKeyword:string,
   searchingKeyword:string,
   focusedSearchResultData : IPageSearchResultData,
   focusedSearchResultData : IPageSearchResultData,
 }
 }
+
+
 const SearchResultContent: FC<Props> = (props: Props) => {
 const SearchResultContent: FC<Props> = (props: Props) => {
-  const page = props.focusedSearchResultData?.pageData || {};
-  if (page == null) return null;
-  // Temporaly workaround for lint error
-  // later needs to be fixed: RevisoinRender to typescriptcomponet
-  const RevisionLoaderTypeAny: any = RevisionLoader;
+  const page = props.focusedSearchResultData?.pageData;
+  // return if page is null
+  if (page == null) return <></>;
   const growiRenderer = props.appContainer.getRenderer('searchresult');
   const growiRenderer = props.appContainer.getRenderer('searchresult');
-  let showTags = false;
-  if (page.tags != null && page.tags.length > 0) { showTags = true }
   return (
   return (
     <div key={page._id} className="search-result-page mb-5">
     <div key={page._id} className="search-result-page mb-5">
-      <h2>
-        <a href={page.path} className="text-break">
-          {page.path}
-        </a>
-        {showTags && (
-          <div className="mt-1 small">
-            <i className="tag-icon icon-tag"></i> {page.tags?.join(', ')}
-          </div>
-        )}
-      </h2>
-      <RevisionLoaderTypeAny
+      <SearchResultContentSubNavigation
+        pageId={page._id}
+        revisionId={page.revision}
+        path={page.path}
+      >
+      </SearchResultContentSubNavigation>
+      <RevisionLoader
         growiRenderer={growiRenderer}
         growiRenderer={growiRenderer}
         pageId={page._id}
         pageId={page._id}
         pagePath={page.path}
         pagePath={page.path}

+ 91 - 0
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -0,0 +1,91 @@
+import React, { FC, useCallback } from 'react';
+import { pagePathUtils } from '@growi/core';
+import PagePathNav from '../PagePathNav';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '../../client/services/AppContainer';
+import TagLabels from '../Page/TagLabels';
+import { toastSuccess, toastError } from '../../client/util/apiNotification';
+import { apiPost } from '../../client/util/apiv1-client';
+import { useSWRTagsInfo } from '../../stores/page';
+import SubNavButtons from '../Navbar/SubNavButtons';
+
+type Props = {
+  appContainer:AppContainer
+  pageId: string,
+  revisionId: string,
+  path: string,
+  isSignleLineMode?: boolean,
+  isCompactMode?: boolean,
+}
+
+
+const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
+  const {
+    appContainer, pageId, revisionId, path, isCompactMode, isSignleLineMode,
+  } = props;
+
+  const { isTrashPage, isDeletablePage } = pagePathUtils;
+
+  const { data: tagInfoData, error: tagInfoError, mutate: mutateTagInfo } = useSWRTagsInfo(pageId);
+
+  const tagsUpdatedHandler = useCallback(async(newTags) => {
+    try {
+      await apiPost('/tags.update', { pageId, tags: newTags });
+      toastSuccess('updated tags successfully');
+      mutateTagInfo();
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  }, [pageId, mutateTagInfo]);
+
+  if (tagInfoError != null || tagInfoData == null) {
+    return <></>;
+  }
+  const isPageDeletable = isDeletablePage(path);
+  const { isSharedUser } = appContainer;
+  const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
+  return (
+    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+      {/* Left side */}
+      <div className="grw-path-nav-container">
+        {!isSharedUser && !isCompactMode && (
+          <div className="grw-taglabels-container">
+            <TagLabels tags={tagInfoData.tags} tagsUpdateInvoked={tagsUpdatedHandler} />
+          </div>
+        )}
+        <PagePathNav pageId={pageId} pagePath={path} isCompactMode={isCompactMode} isSingleLineMode={isSignleLineMode} />
+      </div>
+      {/* Right side */}
+      {/*
+        DeleteCompletely is currently disabled
+        TODO : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
+        story: https://redmine.weseek.co.jp/issues/82222
+      */}
+      <div className="d-flex">
+        <SubNavButtons
+          isCompactMode={isCompactMode}
+          pageId={pageId}
+          revisionId={revisionId}
+          path={path}
+          isDeletable={isPageDeletable}
+          // isAbleToDeleteCompletely={}
+          willShowPageManagement={isAbleToShowPageManagement}
+        >
+        </SubNavButtons>
+      </div>
+    </div>
+  );
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultContentSubNavigationUnstatedWrapper = withUnstatedContainers(SearchResultContentSubNavigation, [AppContainer]);
+
+// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
+const SearchResultContentSubNavigationWrapper = (props) => {
+  return <SearchResultContentSubNavigationUnstatedWrapper {...props}></SearchResultContentSubNavigationUnstatedWrapper>;
+};
+export default SearchResultContentSubNavigationWrapper;

+ 4 - 0
packages/app/src/interfaces/bookmark-info.ts

@@ -0,0 +1,4 @@
+export type IBookmarkInfo = {
+  sumOfBookmarks: number;
+  isBookmarked: boolean,
+};

+ 8 - 0
packages/app/src/interfaces/page-info.ts

@@ -0,0 +1,8 @@
+export type IPageInfo = {
+  sumOfLikers: number;
+  likerIds: string[];
+  seenUserIds: string[];
+  sumOfSeenUsers: number;
+  isSeen: boolean;
+  isLiked: boolean;
+};

+ 3 - 0
packages/app/src/interfaces/pageTagsInfo.ts

@@ -0,0 +1,3 @@
+export type IPageTagsInfo = {
+  tags : string[],
+}

+ 2 - 2
packages/app/src/server/models/page.js

@@ -370,7 +370,7 @@ module.exports = function(crowi) {
       }
       }
       else {
       else {
         logger.debug('liker not updated');
         logger.debug('liker not updated');
-        return reject(self);
+        return reject(new Error('Already liked'));
       }
       }
     }));
     }));
   };
   };
@@ -391,7 +391,7 @@ module.exports = function(crowi) {
       }
       }
       else {
       else {
         logger.debug('liker not updated');
         logger.debug('liker not updated');
-        return reject(self);
+        return reject(new Error('Already unliked'));
       }
       }
     }));
     }));
   };
   };

+ 16 - 0
packages/app/src/stores/bookmark.ts

@@ -0,0 +1,16 @@
+import useSWR, { SWRResponse } from 'swr';
+import { apiv3Get } from '../client/util/apiv3-client';
+import { IBookmarkInfo } from '../interfaces/bookmark-info';
+
+
+export const useSWRBookmarkInfo = (pageId: string): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWR(
+    `/bookmarks/info?pageId=${pageId}`,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        sumOfBookmarks: response.data.sumOfBookmarks,
+        isBookmarked: response.data.isBookmarked,
+      };
+    }),
+  );
+};

+ 27 - 4
packages/app/src/stores/page.tsx

@@ -1,10 +1,12 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
 
 
-import { apiv3Get } from '~/client/util/apiv3-client';
-
-import { IPage } from '~/interfaces/page';
-import { IPagingResult } from '~/interfaces/paging-result';
+import { apiv3Get } from '../client/util/apiv3-client';
+import { apiGet } from '../client/util/apiv1-client';
 
 
+import { IPage } from '../interfaces/page';
+import { IPagingResult } from '../interfaces/paging-result';
+import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
+import { IPageInfo } from '../interfaces/page-info';
 
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxRecentlyUpdated = <Data, Error>(): SWRResponse<IPage[], Error> => {
 export const useSWRxRecentlyUpdated = <Data, Error>(): SWRResponse<IPage[], Error> => {
@@ -31,3 +33,24 @@ export const useSWRxPageList = (
     }),
     }),
   );
   );
 };
 };
+
+export const useSWRPageInfo = (pageId: string): SWRResponse<IPageInfo, Error> => {
+  return useSWR(`/page/info?pageId=${pageId}`, endpoint => apiv3Get(endpoint).then((response) => {
+    return {
+      sumOfLikers: response.data.sumOfLikers,
+      likerIds: response.data.likerIds,
+      seenUserIds: response.data.seenUserIds,
+      sumOfSeenUsers: response.data.sumOfSeenUsers,
+      isSeen: response.data.isSeen,
+      isLiked: response.data?.isLiked,
+    };
+  }));
+};
+
+export const useSWRTagsInfo = (pageId: string): SWRResponse<IPageTagsInfo, Error> => {
+  return useSWR(`/pages.getPageTag?pageId=${pageId}`, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {
+    return {
+      tags: response.tags,
+    };
+  }));
+};

+ 10 - 0
packages/app/src/stores/user.tsx

@@ -0,0 +1,10 @@
+import useSWR, { SWRResponse } from 'swr';
+import { IUser } from '../interfaces/user';
+import { apiGet } from '../client/util/apiv1-client';
+
+export const useSWRxLikerList = (likerIds: string[] = []): SWRResponse<IUser[], Error> => {
+  const shouldFetch = likerIds.length > 0;
+  return useSWR(shouldFetch ? ['/users.list', [...likerIds].join(',')] : null, (endpoint:string, userIds:string) => {
+    return apiGet(endpoint, { user_ids: userIds }).then((response:any) => response.users);
+  });
+};

+ 3 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -589,6 +589,9 @@ body.pathname-sidebar {
  */
  */
 .search-result {
 .search-result {
   .search-result-list {
   .search-result-list {
+    .search-control {
+      background-color: $bgcolor-global;
+    }
     .page-list {
     .page-list {
       .highlighted-keyword {
       .highlighted-keyword {
         background-color: $bgcolor-keyword-highlighted;
         background-color: $bgcolor-keyword-highlighted;