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

Merge pull request #5266 from weseek/imprv/descendants-page-list-modal

Imprv/descendants page list modal
Yuki Takei 4 лет назад
Родитель
Сommit
fb7baf1f93
29 измененных файлов с 657 добавлено и 510 удалено
  1. 7 7
      packages/app/src/client/app.jsx
  2. 4 0
      packages/app/src/client/base.jsx
  3. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  4. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  5. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  6. 3 2
      packages/app/src/components/DescendantsPageList.tsx
  7. 100 0
      packages/app/src/components/DescendantsPageListModal.tsx
  8. 3 3
      packages/app/src/components/EventListeneres/HashChanged.tsx
  9. 3 3
      packages/app/src/components/ForbiddenPage.tsx
  10. 2 1
      packages/app/src/components/Icons/AttachmentIcon.jsx
  11. 2 1
      packages/app/src/components/Icons/HistoryIcon.jsx
  12. 1 1
      packages/app/src/components/Icons/ShareLinkIcon.jsx
  13. 24 11
      packages/app/src/components/IdenticalPathPage.tsx
  14. 54 11
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  15. 3 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  16. 8 4
      packages/app/src/components/Navbar/SubNavButtons.tsx
  17. 0 89
      packages/app/src/components/Page/DisplaySwitcher.jsx
  18. 134 0
      packages/app/src/components/Page/DisplaySwitcher.tsx
  19. 0 40
      packages/app/src/components/PageAccessories.jsx
  20. 0 160
      packages/app/src/components/PageAccessoriesModal.jsx
  21. 134 0
      packages/app/src/components/PageAccessoriesModal.tsx
  22. 2 3
      packages/app/src/components/PageAccessoriesModalControl.jsx
  23. 2 2
      packages/app/src/components/TableOfContents.jsx
  24. 12 2
      packages/app/src/server/routes/apiv3/page.js
  25. 3 0
      packages/app/src/server/views/layout/layout.html
  26. 14 9
      packages/app/src/stores/page.tsx
  27. 74 0
      packages/app/src/stores/ui.tsx
  28. 2 9
      packages/app/src/styles/_page-accessories-control.scss
  29. 0 1
      packages/app/src/styles/_toc.scss

+ 7 - 7
packages/app/src/client/app.jsx

@@ -51,9 +51,9 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -70,10 +70,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -101,7 +100,7 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
 
-  'forbidden-page': <ForbiddenPage isSharePage={appContainer.config.disableLinkSharing} />,
+  'forbidden-page': <ForbiddenPage isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
 
   'page-timeline': <PageTimeline />,
 
@@ -133,7 +132,8 @@ if (pageContainer.state.pageId != null) {
 
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
+  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
   }
 }
 if (pageContainer.state.creator != null) {
@@ -146,8 +146,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     'page': <Page />,
-    'grw-subnav-container': <GrowiContextualSubNavigation />,
-    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'grw-subnav-container': <GrowiContextualSubNavigation isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
     'display-switcher': <DisplaySwitcher />,
   });
 }

+ 4 - 0
packages/app/src/client/base.jsx

@@ -10,9 +10,11 @@ import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageRenameModal from '../components/PageRenameModal';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -46,6 +48,8 @@ const componentMappings = {
   'page-delete-modal': <PageDeleteModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
+  'page-accessories-modal': <PageAccessoriesModal />,
+  'descendants-page-list-modal': <DescendantsPageListModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

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

@@ -1,54 +0,0 @@
-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),
-    });
-  }
-
-}

+ 0 - 96
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,96 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { pagePathUtils } from '@growi/core';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-
-const { isTopPage } = pagePathUtils;
-
-const WIKI_HEADER_LINK = 120;
-
-/**
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- */
-const ContentLinkButtons = (props) => {
-
-  const { appContainer, pageContainer } = props;
-  const { pageUser, path } = pageContainer.state;
-  const { isPageExist } = pageContainer.state;
-  const { isSharedUser } = appContainer;
-
-  const isTopPagePath = isTopPage(path);
-
-  // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const CommentLinkButton = () => {
-    return (
-      <div className="mt-3">
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-        >
-          <i className="mr-2 icon-fw icon-bubbles"></i>
-          <span>Comments</span>
-        </button>
-      </div>
-    );
-  };
-
-  const BookMarkLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="fa fa-fw fa-bookmark-o"></i>
-        <span>Bookmarks</span>
-      </button>
-
-    );
-  };
-
-  const RecentlyCreatedLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-
-    );
-  };
-
-  return (
-    <>
-      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
-
-      <div className="mt-3 d-flex justify-content-between">
-        {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
-      </div>
-    </>
-  );
-
-};
-
-ContentLinkButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 66 - 0
packages/app/src/components/ContentLinkButtons.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback, useMemo } from 'react';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageUser } from '~/stores/context';
+
+const WIKI_HEADER_LINK = 120;
+
+
+const ContentLinkButtons = (): JSX.Element => {
+
+  const { data: pageUser } = usePageUser();
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+
+  const BookMarkLinkButton = useCallback((): JSX.Element => {
+    if (getBookMarkListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    );
+  }, [getBookMarkListHeaderDom]);
+
+  const RecentlyCreatedLinkButton = useCallback(() => {
+    if (getRecentlyCreatedListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    );
+  }, [getRecentlyCreatedListHeaderDom]);
+
+  if (pageUser == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="mt-3 d-flex justify-content-between">
+      <BookMarkLinkButton />
+      <RecentlyCreatedLinkButton />
+    </div>
+  );
+
+};
+
+export default ContentLinkButtons;

+ 3 - 2
packages/app/src/components/DescendantsPageList.tsx

@@ -3,7 +3,7 @@ import {
   IPageHasId, IPageWithMeta,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 
 import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 
@@ -25,8 +25,9 @@ const DescendantsPageList = (props: Props): JSX.Element => {
   const [activePage, setActivePage] = useState(1);
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
 
-  const { data: pagingResult, error } = useSWRxPageList(path, activePage);
+  const { data: pagingResult, error } = useSWRxPageList(isSharedUser ? null : path, activePage);
 
   const pageIds = pagingResult?.items?.map(page => page._id);
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);

+ 100 - 0
packages/app/src/components/DescendantsPageListModal.tsx

@@ -0,0 +1,100 @@
+
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useIsSharedUser } from '~/stores/context';
+
+import DescendantsPageList from './DescendantsPageList';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import PageTimeline from './PageTimeline';
+
+
+type Props = {
+}
+
+export const DescendantsPageListModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: status, close } = useDescendantsPageListModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: () => {
+          if (status == null || status.path == null || !status.isOpened) {
+            return <></>;
+          }
+          return <DescendantsPageList path={status.path} />;
+        },
+        i18n: t('page_list'),
+        index: 0,
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: TimeLineIcon,
+        Content: () => <PageTimeline />,
+        i18n: t('Timeline View'),
+        index: 1,
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, status, t]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={v => setActiveTab(v)}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody>
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+
+};

+ 3 - 3
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,4 +1,4 @@
-import { FC, useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
@@ -6,7 +6,7 @@ import { useIsEditable } from '~/stores/context';
 /**
  * Change editorMode by browser forward/back operation
  */
-const HashChanged: FC<void> = () => {
+const HashChanged = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
@@ -33,7 +33,7 @@ const HashChanged: FC<void> = () => {
 
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
 
-  return null;
+  return <></>;
 };
 
 export default HashChanged;

+ 3 - 3
packages/app/src/components/ForbiddenPage.tsx

@@ -7,7 +7,7 @@ import DescendantsPageList from './DescendantsPageList';
 
 
 type Props = {
-  isSharePage?: boolean,
+  isLinkSharingDisabled?: boolean,
 }
 
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
@@ -39,12 +39,12 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            { props.isSharePage ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
+            { props.isLinkSharingDisabled ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
 
-      { !props.isSharePage && (
+      { !props.isLinkSharingDisabled && (
         <div className="mt-5">
           <CustomNavAndContents navTabMapping={navTabMapping} />
         </div>

+ 2 - 1
packages/app/src/components/Icons/AttachmentIcon.jsx

@@ -4,7 +4,8 @@ const Attachment = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <g className="cls-1">

+ 2 - 1
packages/app/src/components/Icons/HistoryIcon.jsx

@@ -4,7 +4,8 @@ const RecentChanges = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <path

+ 1 - 1
packages/app/src/components/Icons/ShareLinkIcon.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 const ShareLink = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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)">

+ 24 - 11
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,15 +1,15 @@
-import React, {
-  FC,
-} from 'react';
+import React, { FC } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { DevidedPagePath } from '@growi/core';
 
-import { useCurrentPagePath } from '~/stores/context';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useSWRxPageInfoForList } from '~/stores/page';
 
+import PageListIcon from './Icons/PageListIcon';
 import { PageListItemL } from './PageList/PageListItemL';
-import { useSWRxPageInfoForList } from '~/stores/page';
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 
 
 type IdenticalPathAlertProps = {
@@ -55,24 +55,37 @@ type IdenticalPathPageProps= {
 const jsonNull = 'null';
 
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+  const { t } = useTranslation();
 
   const identicalPageDocument = document.getElementById('identical-path-page');
   const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
 
   const pageIds = pages.map(page => page._id) as string[];
 
-  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
 
   const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
 
       <div className="grw-side-contents-container">
-        <div className="grw-side-contents-sticky-container">
-          <div className="border-bottom pb-1">
-            {/* <PageAccessories isNotFoundPage={!isPageExist} /> */}
-          </div>
+        <div className="grw-page-accessories-control pb-1">
+          { currentPath != null && !isSharedUser && (
+            <button
+              type="button"
+              className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between"
+              onClick={() => openDescendantPageListModal(currentPath)}
+            >
+              <PageListIcon />
+              {t('page_list')}
+              <span></span> {/* for a count badge */}
+            </button>
+          ) }
         </div>
       </div>
 

+ 54 - 11
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -9,21 +9,25 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
 } from '~/stores/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 
-import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
-import { SubNavButtons } from './SubNavButtons';
-import PageEditorModeManager from './PageEditorModeManager';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { IPageHasId } from '~/interfaces/page';
+
+import HistoryIcon from '../Icons/HistoryIcon';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { SubNavButtons } from './SubNavButtons';
+import PageEditorModeManager from './PageEditorModeManager';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import PresentationIcon from '../Icons/PresentationIcon';
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -32,17 +36,21 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
   pageId: string,
   revisionId: string,
+  isLinkSharingDisabled?: boolean,
 }
 
 const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { pageId, revisionId } = props;
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { open } = usePageAccessoriesModal();
 
   return (
     <>
-      <DropdownItem divider />
-
       {/* Presentation */}
       <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
         <i className="icon-fw"><PresentationIcon /></i>
@@ -57,6 +65,35 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
       <DropdownItem divider />
 
+      {/*
+        TODO: show Tooltip when menu is disabled
+        refs: PageAccessoriesModalControl
+      */}
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.PageHistory)}
+        disabled={isGuestUser || isSharedUser}
+      >
+        <span className="mr-1"><HistoryIcon /></span>
+        {t('History')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.Attachment)}
+      >
+        <span className="mr-1"><AttachmentIcon /></span>
+        {t('attachment_data')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.ShareLink)}
+        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+      >
+        <span className="mr-1"><ShareLinkIcon /></span>
+        {t('share_links.share_link_management')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
       {/* Create template */}
       <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
         <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
@@ -79,6 +116,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: revisionAuthor } = useRevisionAuthor();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -88,7 +126,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
   const {
-    editorContainer, isCompactMode,
+    editorContainer, isCompactMode, isLinkSharingDisabled,
   } = props;
 
   const isViewMode = editorMode === EditorMode.View;
@@ -127,10 +165,13 @@ const GrowiContextualSubNavigation = (props) => {
             <SubNavButtons
               isCompactMode={isCompactMode}
               pageId={pageId}
+              shareLinkId={shareLinkId}
               revisionId={revisionId}
               disableSeenUserInfoPopover={isSharedUser}
               showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} />}
+              additionalMenuItemRenderer={props => (
+                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+              )}
             />
           ) }
         </div>
@@ -149,7 +190,8 @@ const GrowiContextualSubNavigation = (props) => {
   }, [
     pageId, revisionId,
     editorMode, mutateEditorMode,
-    isCompactMode, isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    isCompactMode, isLinkSharingDisabled,
+    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
   ]);
 
@@ -195,6 +237,7 @@ GrowiContextualSubNavigation.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   isCompactMode: PropTypes.bool,
+  isLinkSharingDisabled: PropTypes.bool,
 };
 
 export default GrowiContextualSubNavigationWrapper;

+ 3 - 1
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,6 +1,7 @@
 import React, {
   useMemo, useState, useRef, useEffect, useCallback,
 } from 'react';
+import PropTypes from 'prop-types';
 
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
@@ -110,13 +111,14 @@ const GrowiSubNavigationSwitcher = (props) => {
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
       <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
-        <GrowiContextualSubNavigation isCompactMode />
+        <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
       </div>
     </div>
   );
 };
 
 GrowiSubNavigationSwitcher.propTypes = {
+  isLinkSharingDisabled: PropTypes.bool,
 };
 
 export default GrowiSubNavigationSwitcher;

+ 8 - 4
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -24,18 +24,21 @@ type CommonProps = {
 
 type SubNavButtonsSubstanceProps= CommonProps & {
   pageId: string,
+  shareLinkId?: string | null,
   revisionId: string,
   pageInfo: IPageInfoAll,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
-    pageInfo, pageId, isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    pageInfo,
+    pageId, shareLinkId,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
 
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
 
@@ -129,13 +132,14 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 type SubNavButtonsProps= CommonProps & {
   pageId: string,
+  shareLinkId?: string | null,
   revisionId?: string | null,
 };
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
-  const { pageId, revisionId } = props;
+  const { pageId, shareLinkId, revisionId } = props;
 
-  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null);
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
   if (revisionId == null || error != null) {
     return <></>;

+ 0 - 89
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,89 +0,0 @@
-import React from 'react';
-import { TabContent, TabPane } from 'reactstrap';
-import propTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-import Editor from '../PageEditor';
-import Page from '../Page';
-import UserInfo from '../User/UserInfo';
-import TableOfContents from '../TableOfContents';
-import ContentLinkButtons from '../ContentLinkButtons';
-import PageAccessories from '../PageAccessories';
-import PageEditorByHackmd from '../PageEditorByHackmd';
-import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import HashChanged from '../EventListeneres/HashChanged';
-import { useIsEditable } from '~/stores/context';
-
-
-const DisplaySwitcher = (props) => {
-  const {
-    pageContainer,
-  } = props;
-  const { isPageExist, pageUser } = pageContainer.state;
-
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-
-  const isViewMode = editorMode === EditorMode.View;
-
-  return (
-    <>
-      <TabContent activeTab={editorMode}>
-        <TabPane tabId={EditorMode.View}>
-          <div className="d-flex flex-column flex-lg-row-reverse">
-
-            { isPageExist && (
-              <div className="grw-side-contents-container">
-                <div className="grw-side-contents-sticky-container">
-                  <div className="border-bottom pb-1">
-                    <PageAccessories />
-                  </div>
-
-                  <div className="d-none d-lg-block">
-                    <div id="revision-toc" className="revision-toc">
-                      <TableOfContents />
-                    </div>
-                    <ContentLinkButtons />
-                  </div>
-                </div>
-              </div>
-            ) }
-
-            <div className="flex-grow-1 flex-basis-0 mw-0">
-              {pageUser && <UserInfo pageUser={pageUser} />}
-              <Page />
-            </div>
-
-          </div>
-        </TabPane>
-        { isEditable && (
-          <TabPane tabId={EditorMode.Editor}>
-            <div id="page-editor">
-              <Editor />
-            </div>
-          </TabPane>
-        ) }
-        { isEditable && (
-          <TabPane tabId={EditorMode.HackMD}>
-            <div id="page-editor-with-hackmd">
-              <PageEditorByHackmd />
-            </div>
-          </TabPane>
-        ) }
-      </TabContent>
-      { isEditable && !isViewMode && <EditorNavbarBottom /> }
-
-      { isEditable && <HashChanged></HashChanged> }
-    </>
-  );
-};
-
-DisplaySwitcher.propTypes = {
-  pageContainer: propTypes.instanceOf(PageContainer).isRequired,
-};
-
-
-export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 134 - 0
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -0,0 +1,134 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode, useDescendantsPageListModal } from '~/stores/ui';
+import {
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+} from '~/stores/context';
+
+
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+
+import PageListIcon from '../Icons/PageListIcon';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import UserInfo from '../User/UserInfo';
+import TableOfContents from '../TableOfContents';
+import ContentLinkButtons from '../ContentLinkButtons';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+
+
+const WIKI_HEADER_LINK = 120;
+
+const { isTopPage } = pagePathUtils;
+
+
+const DisplaySwitcher = (): JSX.Element => {
+  const { t } = useTranslation();
+
+
+  // get element for smoothScroll
+  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isUserPage } = useIsUserPage();
+  const { data: isEditable } = useIsEditable();
+  const { data: pageUser } = usePageUser();
+
+  const { data: editorMode } = useEditorMode();
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
+  const isPageExist = currentPageId != null;
+  const isViewMode = editorMode === EditorMode.View;
+  const isTopPagePath = isTopPage(currentPath ?? '');
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId={EditorMode.View}>
+          <div className="d-flex flex-column flex-lg-row-reverse">
+
+            { isPageExist && (
+              <div className="grw-side-contents-container">
+                <div className="grw-side-contents-sticky-container">
+
+                  {/* Page list */}
+                  <div className="grw-page-accessories-control">
+                    { currentPath != null && !isSharedUser && (
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => openDescendantPageListModal(currentPath)}
+                      >
+                        <PageListIcon />
+                        {t('page_list')}
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    ) }
+                  </div>
+
+                  {/* Comments */}
+                  { getCommentListDom != null && !isTopPagePath && (
+                    <div className="mt-2">
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                      >
+                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <span>Comments</span>
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    </div>
+                  ) }
+
+                  <div className="d-none d-lg-block">
+                    <div id="revision-toc" className="revision-toc">
+                      <TableOfContents />
+                    </div>
+                    <ContentLinkButtons />
+                  </div>
+
+                </div>
+              </div>
+            ) }
+
+            <div className="flex-grow-1 flex-basis-0 mw-0">
+              { isUserPage && <UserInfo pageUser={pageUser} />}
+              <Page />
+            </div>
+
+          </div>
+        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
+      </TabContent>
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
+    </>
+  );
+};
+
+export default DisplaySwitcher;

+ 0 - 40
packages/app/src/components/PageAccessories.jsx

@@ -1,40 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PageAccessoriesModalControl from './PageAccessoriesModalControl';
-import PageAccessoriesModal from './PageAccessoriesModal';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-
-const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer } = props;
-  const { isGuestUser, isSharedUser } = appContainer;
-
-  return (
-    <>
-      <PageAccessoriesModalControl
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-      />
-      <PageAccessoriesModal
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-      />
-    </>
-  );
-};
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
-
-PageAccessories.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-};
-
-export default PageAccessoriesWrapper;

+ 0 - 160
packages/app/src/components/PageAccessoriesModal.jsx

@@ -1,160 +0,0 @@
-import React, { useCallback, useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalBody, ModalHeader, TabContent, TabPane,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import PageAttachment from './PageAttachment';
-import PageTimeline from './PageTimeline';
-import DescendantsPageList from './DescendantsPageList';
-import PageHistory from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import ExpandOrContractButton from './ExpandOrContractButton';
-
-const PageAccessoriesModal = (props) => {
-  const {
-    t, pageContainer, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-  const { switchActiveTab } = pageAccessoriesContainer;
-  const { activeTab, activeComponents } = pageAccessoriesContainer.state;
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        i18n: t('page_list'),
-        index: 0,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      timeline: {
-        Icon: TimeLineIcon,
-        i18n: t('Timeline View'),
-        index: 1,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      pageHistory: {
-        Icon: HistoryIcon,
-        i18n: t('History'),
-        index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser,
-      },
-      attachment: {
-        Icon: AttachmentIcon,
-        i18n: t('attachment_data'),
-        index: 3,
-      },
-      shareLink: {
-        Icon: ShareLinkIcon,
-        i18n: t('share_links.share_link_management'),
-        index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
-      },
-    };
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
-
-  const closeModalHandler = useCallback(() => {
-    if (onClose == null) {
-      return;
-    }
-    onClose();
-  }, [onClose]);
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-  };
-
-  const buttons = (
-    <div className="d-flex flex-nowrap">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    </div>
-  );
-
-  return (
-    <React.Fragment>
-      <Modal
-        size="xl"
-        isOpen={props.isOpen}
-        toggle={closeModalHandler}
-        className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-      >
-        <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
-          <CustomNavTab
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            onNavSelected={switchActiveTab}
-            breakpointToHideInactiveTabsDown="md"
-            hideBorderBottom
-          />
-        </ModalHeader>
-        <ModalBody className="overflow-auto grw-modal-body-style">
-          {/* Do not use CustomTabContent because of performance problem:
-              the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="pagelist">
-              {activeComponents.has('pagelist') && <DescendantsPageList path={pageContainer.state.path} />}
-            </TabPane>
-            <TabPane tabId="timeline">
-              {activeComponents.has('timeline') && <PageTimeline /> }
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="pageHistory">
-                {activeComponents.has('pageHistory') && <PageHistory /> }
-              </TabPane>
-            )}
-            <TabPane tabId="attachment">
-              {activeComponents.has('attachment') && <PageAttachment />}
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="shareLink">
-                {activeComponents.has('shareLink') && <ShareLink />}
-              </TabPane>
-            )}
-          </TabContent>
-        </ModalBody>
-      </Modal>
-    </React.Fragment>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageContainer, PageAccessoriesContainer]);
-
-PageAccessoriesModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-};
-
-export default withTranslation()(PageAccessoriesModalWrapper);

+ 134 - 0
packages/app/src/components/PageAccessoriesModal.tsx

@@ -0,0 +1,134 @@
+import React, { useMemo, useState } from 'react';
+
+import {
+  Modal, ModalBody, ModalHeader, TabContent, TabPane,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/ui';
+import AppContainer from '~/client/services/AppContainer';
+
+import HistoryIcon from './Icons/HistoryIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageAttachment from './PageAttachment';
+import PageHistory from './PageHistory';
+import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import ExpandOrContractButton from './ExpandOrContractButton';
+
+
+type Props = {
+  appContainer: AppContainer,
+  isLinkSharingDisabled: boolean,
+}
+
+const PageAccessoriesModal = (props: Props): JSX.Element => {
+  const {
+    appContainer,
+  } = props;
+
+  const isLinkSharingDisabled = appContainer.config.disableLinkSharing;
+
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState(PageAccessoriesModalContents.PageHistory);
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: status, open, close } = usePageAccessoriesModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [PageAccessoriesModalContents.PageHistory]: {
+        Icon: HistoryIcon,
+        i18n: t('History'),
+        index: 0,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser,
+      },
+      [PageAccessoriesModalContents.Attachment]: {
+        Icon: AttachmentIcon,
+        i18n: t('attachment_data'),
+        index: 1,
+      },
+      [PageAccessoriesModalContents.ShareLink]: {
+        Icon: ShareLinkIcon,
+        i18n: t('share_links.share_link_management'),
+        index: 2,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
+      },
+    };
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened, activatedContents } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={(v) => {
+            setActiveTab(v);
+            open(v);
+          }}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody className="overflow-auto grw-modal-body-style">
+        {/* Do not use CustomTabContent because of performance problem:
+            the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activatedContents */}
+        <TabContent activeTab={activeTab}>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.PageHistory}>
+              {activatedContents.has(PageAccessoriesModalContents.PageHistory) && <PageHistory /> }
+            </TabPane>
+          )}
+          <TabPane tabId={PageAccessoriesModalContents.Attachment}>
+            {activatedContents.has(PageAccessoriesModalContents.Attachment) && <PageAttachment />}
+          </TabPane>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.ShareLink}>
+              {activatedContents.has(PageAccessoriesModalContents.ShareLink) && <ShareLink />}
+            </TabPane>
+          )}
+        </TabContent>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [AppContainer]);
+
+export default PageAccessoriesModalWrapper;

+ 2 - 3
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
@@ -96,12 +95,12 @@ const PageAccessoriesModalControl = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 PageAccessoriesModalControl.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
 
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.any,
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,

+ 2 - 2
packages/app/src/components/TableOfContents.jsx

@@ -34,8 +34,8 @@ const TableOfContents = (props) => {
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
 
-    // get smaller bottom line of window height - the height of ContentLinkButtons and .system-version height) and containerTop
-    let bottom = Math.min(window.innerHeight - 41 - 20, parentBottom);
+    // get smaller bottom line of window height - .system-version height - margin 5px) and containerTop
+    let bottom = Math.min(window.innerHeight - 20 - 5, parentBottom);
 
     if (isUserPage) {
       // raise the bottom line by the height and margin-top of UserContentLinks

+ 12 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -159,6 +159,7 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
@@ -354,8 +355,8 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.get('/info', loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
-    const { user } = req;
+  router.get('/info', certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+    const { user, isSharedPage } = req;
     const { pageId } = req.query;
 
     try {
@@ -365,6 +366,15 @@ module.exports = (crowi) => {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
 
+      if (isSharedPage) {
+        return {
+          isEmpty: page.isEmpty,
+          isMovable: false,
+          isDeletable: false,
+          isAbleToDeleteCompletely: false,
+        };
+      }
+
       const isGuestUser = !req.user;
       const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 

+ 3 - 0
packages/app/src/server/views/layout/layout.html

@@ -107,6 +107,9 @@
 <div id="page-delete-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-rename-modal"></div>
+<div id="page-accessories-modal"></div>
+<div id="descendants-page-list-modal"></div>
+
 {% include '../modal/shortcuts.html' %}
 
 {% block body_end %}

+ 14 - 9
packages/app/src/stores/page.tsx

@@ -32,13 +32,14 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> =>
 };
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxPageList = (
-    path: string,
-    pageNumber?: number,
-): SWRResponse<IPagingResult<IPageHasId>, Error> => {
-  const page = pageNumber || 1;
+export const useSWRxPageList = (path: string | null, pageNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
+
+  const key = path != null
+    ? `/pages/list?path=${path}&page=${pageNumber ?? 1}`
+    : null;
+
   return useSWR(
-    `/pages/list?path=${path}&page=${page}`,
+    key,
     endpoint => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
       return {
         items: response.data.pages,
@@ -59,10 +60,14 @@ export const useSWRTagsInfo = (pageId: string | null | undefined): SWRResponse<I
   }));
 };
 
-export const useSWRxPageInfo = (pageId: string | null | undefined): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
+export const useSWRxPageInfo = (
+    pageId: string | null | undefined,
+    shareLinkId?: string | null,
+): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
+
   return useSWRImmutable(
-    pageId != null ? ['/page/info', pageId] : null,
-    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+    pageId != null ? ['/page/info', pageId, shareLinkId] : null,
+    (endpoint, pageId, shareLinkId) => apiv3Get(endpoint, { pageId, shareLinkId }).then(response => response.data),
   );
 };
 

+ 74 - 0
packages/app/src/stores/ui.tsx

@@ -416,6 +416,80 @@ export const usePageRenameModalOpened = (): SWRResponse<boolean, Error> => {
   );
 };
 
+
+type DescendantsPageListModalStatus = {
+  isOpened: boolean,
+  path?: string,
+}
+
+type DescendantsPageListUtils = {
+  open(path: string): Promise<DescendantsPageListModalStatus | undefined>
+  close(): Promise<DuplicateModalStatus | undefined>
+}
+
+export const useDescendantsPageListModal = (
+    status?: DescendantsPageListModalStatus,
+): SWRResponse<DescendantsPageListModalStatus, Error> & DescendantsPageListUtils => {
+
+  const initialData: DescendantsPageListModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<DescendantsPageListModalStatus, Error>('descendantsPageListModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (path: string) => swrResponse.mutate({ isOpened: true, path }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+
+export const PageAccessoriesModalContents = {
+  PageHistory: 'PageHistory',
+  Attachment: 'Attachment',
+  ShareLink: 'ShareLink',
+} as const;
+export type PageAccessoriesModalContents = typeof PageAccessoriesModalContents[keyof typeof PageAccessoriesModalContents];
+
+type PageAccessoriesModalStatus = {
+  isOpened: boolean,
+  activatedContents: Set<PageAccessoriesModalContents>,
+}
+
+type PageAccessoriesModalUtils = {
+  open(activatedContent: PageAccessoriesModalContents): Promise<PageAccessoriesModalStatus> | void
+  close(): Promise<PageAccessoriesModalStatus> | void
+}
+
+export const usePageAccessoriesModal = (
+    status?: PageAccessoriesModalStatus,
+): SWRResponse<PageAccessoriesModalStatus, Error> & PageAccessoriesModalUtils => {
+
+  const initialData: PageAccessoriesModalStatus = { isOpened: false, activatedContents: new Set<PageAccessoriesModalContents>() };
+  const swrResponse = useStaticSWR<PageAccessoriesModalStatus, Error>('pageAccessoriesModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (activatedContent: PageAccessoriesModalContents) => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({
+        isOpened: true,
+        activatedContents: swrResponse.data.activatedContents.add(activatedContent),
+      });
+    },
+    close: () => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({
+        isOpened: false,
+        activatedContents: swrResponse.data.activatedContents,
+      });
+    },
+  };
+};
+
+
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);
 };

+ 2 - 9
packages/app/src/styles/_page-accessories-control.scss

@@ -1,18 +1,11 @@
 .grw-page-accessories-control {
-  line-height: 1.25;
-  border-bottom: 1px solid transparent;
-
   .grw-btn-page-accessories {
-    padding: 0.375rem;
+    padding-right: 1rem;
+    padding-left: 1rem;
 
     svg {
       width: 16px;
       height: 16px;
     }
   }
-
-  .grw-border-vr {
-    height: 25px;
-    border-left: solid 1px transparent;
-  }
 }

+ 0 - 1
packages/app/src/styles/_toc.scss

@@ -4,7 +4,6 @@
   padding: 5px;
   font-size: 0.9em;
 
-  border-top: 1px solid transparent;
   border-bottom: 1px solid transparent;
 
   .revision-toc-content {