Browse Source

Merge pull request #2987 from weseek/feat/article-area-renovation

Feat/article area renovation
Yuki Takei 5 years ago
parent
commit
edacb2979c
31 changed files with 352 additions and 225 deletions
  1. 5 0
      resource/locales/en_US/translation.json
  2. 5 0
      resource/locales/ja_JP/translation.json
  3. 6 1
      resource/locales/zh_CN/translation.json
  4. 6 7
      src/client/js/app.jsx
  5. 1 0
      src/client/js/components/Admin/App/GcsSettings.jsx
  6. 113 39
      src/client/js/components/CustomNavigation.jsx
  7. 0 0
      src/client/js/components/Icons/HistoryIcon.jsx
  8. 5 3
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  9. 4 4
      src/client/js/components/NotFoundPage.jsx
  10. 7 1
      src/client/js/components/Page/NotFoundAlert.jsx
  11. 20 2
      src/client/js/components/Page/PageManagement.jsx
  12. 5 5
      src/client/js/components/Page/TagEditModal.jsx
  13. 19 11
      src/client/js/components/Page/TagLabels.jsx
  14. 57 99
      src/client/js/components/PageAccessoriesModal.jsx
  15. 17 16
      src/client/js/components/PageComment/Comment.jsx
  16. 4 2
      src/client/js/components/TableOfContents.jsx
  17. 27 17
      src/client/js/components/TopOfTableContents.jsx
  18. 2 2
      src/client/js/components/TrashPageList.jsx
  19. 0 5
      src/client/js/legacy/crowi.js
  20. 9 0
      src/client/js/services/NavigationContainer.js
  21. 2 2
      src/client/js/services/PageContainer.js
  22. 5 0
      src/client/styles/scss/_comment.scss
  23. 6 0
      src/client/styles/scss/theme/_apply-colors-dark.scss
  24. 6 0
      src/client/styles/scss/theme/_apply-colors-light.scss
  25. 11 0
      src/client/styles/scss/theme/_apply-colors.scss
  26. 1 2
      src/server/routes/apiv3/app-settings.js
  27. 4 3
      src/server/routes/apiv3/page.js
  28. 0 1
      src/server/views/layout-growi/forbidden.html
  29. 2 0
      src/server/views/widget/forbidden_content.html
  30. 1 1
      src/server/views/widget/not_creatable_content.html
  31. 2 2
      src/server/views/widget/page_content.html

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

@@ -1,5 +1,6 @@
 {
   "Help": "Help",
+  "view": "View",
   "Edit": "Edit",
   "Delete": "Delete",
   "delete_all": "Delete all",
@@ -300,6 +301,9 @@
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
     }
   },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+  },
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
@@ -439,6 +443,7 @@
     "open_sandbox": "Open Sandbox"
   },
   "hackmd": {
+    "hack_md": "HackMD",
     "not_set_up": "HackMD is not set up.",
     "used_for_not_found": "Can not use HackMD to a page that does not exist.",
     "start_to_edit": "Start to edit with HackMD",

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

@@ -1,5 +1,6 @@
 {
   "Help": "ヘルプ",
+  "view": "View",
   "Edit": "編集",
   "Delete": "削除",
   "delete_all": "全て削除",
@@ -302,6 +303,9 @@
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
     }
   },
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "投稿時のページを表示する"
+  },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
@@ -441,6 +445,7 @@
     "open_sandbox": "Sandbox を開く"
   },
   "hackmd":{
+    "hack_md": "HackMD",
     "not_set_up": "HackMD はセットアップされていません",
     "used_for_not_found": "HackMD は新しいページの作成には利用できません",
     "start_to_edit": "HackMD を開始する",

+ 6 - 1
resource/locales/zh_CN/translation.json

@@ -1,5 +1,6 @@
 {
-	"Help": "帮助",
+  "Help": "帮助",
+  "view": "View",
 	"Edit": "编辑",
 	"Delete": "删除",
 	"delete_all": "删除所有",
@@ -280,6 +281,9 @@
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
 	},
+  "page_comment": {
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+  },
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "新建页面已存在",
@@ -415,6 +419,7 @@
 		"open_sandbox": "开放式沙箱"
 	},
 	"hackmd": {
+    "hack_md": "HackMD",
     "not_set_up": "HackMD is not set up.",
     "used_for_not_found": "Can not use HackMD to a page that does not exist.",
 		"start_to_edit": "Start to edit with HackMD",

+ 6 - 7
src/client/js/app.jsx

@@ -80,7 +80,11 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
 
-  'not-found-alert': <NotFoundAlert onPageCreateClicked={navigationContainer.setEditorMode} />,
+  'not-found-alert': <NotFoundAlert
+    onPageCreateClicked={navigationContainer.setEditorMode}
+    isForbidden={pageContainer.state.isForbidden}
+    isNotCreatable={pageContainer.state.isNotCreatable}
+  />,
 
   'page-timeline': <PageTimeline />,
 
@@ -95,7 +99,7 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
-    'revision-toc': <TableOfContents />,
+    'revision-toc': <TableOfContents isGuestUserMode={appContainer.currentUser == null} />,
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
 
@@ -115,11 +119,6 @@ if (pageContainer.state.path != null) {
     'page': <Page />,
     'grw-subnav-container': <GrowiSubNavigation />,
     'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
-  });
-}
-// additional definitions if user is logged in
-if (appContainer.currentUser != null) {
-  Object.assign(componentMappings, {
     'display-switcher': <DisplaySwitcher />,
   });
 }

+ 1 - 0
src/client/js/components/Admin/App/GcsSettings.jsx

@@ -18,6 +18,7 @@ function GcsSetting(props) {
       {gcsUseOnlyEnvVars && (
         <p
           className="alert alert-info"
+          // eslint-disable-next-line react/no-danger
           dangerouslySetInnerHTML={{ __html: t('admin:app_setting.note_for_the_only_env_option', { env: 'IS_GCS_ENV_PRIORITIZED' }) }}
         />
       )}

+ 113 - 39
src/client/js/components/CustomNavigation.jsx

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

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


+ 5 - 3
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -144,6 +144,8 @@ const GrowiSubNavigation = (props) => {
 
   const { currentUser } = appContainer;
   const isPageNotFound = pageId == null;
+  // Tags cannot be edited while the new page and editorMode is view
+  const isTagLabelHidden = (editorMode !== 'edit' && isPageNotFound);
   const isUserPage = pageUser != null;
   const isPageInTrash = isTrashPage(path);
 
@@ -163,9 +165,9 @@ const GrowiSubNavigation = (props) => {
         ) }
 
         <div className="grw-path-nav-container">
-          { !isCompactMode && !isPageNotFound && !isPageForbidden && !isUserPage && (
+          { !isCompactMode && !isTagLabelHidden && !isPageForbidden && !isUserPage && (
             <div className="mb-2">
-              <TagLabels />
+              <TagLabels editorMode={editorMode} />
             </div>
           ) }
 
@@ -193,7 +195,7 @@ const GrowiSubNavigation = (props) => {
             { !isPageNotFound && !isPageForbidden && <PageManagement /> }
           </div>
           <div className="mt-2">
-            { !isCreatable && !isPageInTrash
+            { !isCreatable && !isPageInTrash && !isPageForbidden
             && (
             <ThreeStrandedButton
               onThreeStrandedButtonClicked={onThreeStrandedButtonClicked}

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

@@ -12,15 +12,15 @@ const NotFoundPage = (props) => {
 
   const navTabMapping = {
     pagelist: {
-      icon: <PageListIcon />,
+      Icon: PageListIcon,
+      Content: PageList,
       i18n: t('page_list'),
-      tabContent: <PageList />,
       index: 0,
     },
     timeLine: {
-      icon: <TimeLineIcon />,
+      Icon: TimeLineIcon,
+      Content: PageTimeline,
       i18n: t('Timeline View'),
-      tabContent: <PageTimeline />,
       index: 1,
     },
   };

+ 7 - 1
src/client/js/components/Page/NotFoundAlert.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 const NotFoundAlert = (props) => {
-  const { t } = props;
+  const { t, isForbidden, isNotCreatable } = props;
   function clickHandler(viewType) {
     if (props.onPageCreateClicked === null) {
       return;
@@ -11,6 +11,10 @@ const NotFoundAlert = (props) => {
     props.onPageCreateClicked(viewType);
   }
 
+  if (isForbidden || isNotCreatable) {
+    return null;
+  }
+
   return (
     <div className="border border-info m-4 p-3">
       <div className="col-md-12 p-0">
@@ -35,6 +39,8 @@ const NotFoundAlert = (props) => {
 NotFoundAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   onPageCreateClicked: PropTypes.func,
+  isForbidden: PropTypes.bool.isRequired,
+  isNotCreatable: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(NotFoundAlert);

+ 20 - 2
src/client/js/components/Page/PageManagement.jsx

@@ -100,6 +100,24 @@ const PageManagement = (props) => {
   //   setIsArchiveCreateModalShown(false);
   // }
 
+  function renderDropdownItemForTopPage() {
+    return (
+      <>
+        <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
+          <i className="icon-fw icon-docs"></i> { t('Duplicate') }
+        </button>
+        {/* TODO Presentation Mode is not function. So if it is really necessary, survey this cause and implement Presentation Mode in top page */}
+        {/* <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
+          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
+        </button> */}
+        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
+        </button>
+        <div className="dropdown-divider"></div>
+      </>
+    );
+  }
+
   function renderDropdownItemForNotTopPage() {
     return (
       <>
@@ -110,7 +128,7 @@ const PageManagement = (props) => {
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
         </button>
         <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
-          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
+          <i className="icon-fw"><PresentationIcon /></i> { t('Presentation Mode') }
         </button>
         <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
           <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
@@ -206,7 +224,7 @@ const PageManagement = (props) => {
     <>
       {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
-        {!isTopPagePath && renderDropdownItemForNotTopPage()}
+        {isTopPagePath ? renderDropdownItemForTopPage() : renderDropdownItemForNotTopPage()}
         <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>
           <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
         </button>

+ 5 - 5
src/client/js/components/Page/TagEditModal.jsx

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 import TagsInput from './TagsInput';
@@ -37,15 +37,15 @@ function TagEditModal(props) {
   return (
     <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
-          Edit Tags
+        Edit Tags
       </ModalHeader>
       <ModalBody>
         <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
       </ModalBody>
       <ModalFooter>
-        <Button color="primary" onClick={handleSubmit}>
-            Done
-        </Button>
+        <button type="button" className="btn btn-primary" onClick={handleSubmit}>
+          Done
+        </button>
       </ModalFooter>
     </Modal>
   );

+ 19 - 11
src/client/js/components/Page/TagLabels.jsx

@@ -28,11 +28,12 @@ class TagLabels extends React.Component {
 
   /**
    * @return tags data
-   *   1. pageContainer.state.tags if pageId is not null
-   *   2. editorContainer.state.tags if pageId is null
+   *   1. pageContainer.state.tags if editorMode is view
+   *   2. editorContainer.state.tags if editorMode is edit
    */
-  getEditTargetData() {
-    return (this.props.editorContainer.state.pageId != null) ? this.props.editorContainer.state.tags : this.props.pageContainer.state.tags;
+  getTagData() {
+    const { editorContainer, pageContainer, editorMode } = this.props;
+    return (editorMode === 'edit') ? editorContainer.state.tags : pageContainer.state.tags;
   }
 
   openEditorModal() {
@@ -43,20 +44,26 @@ class TagLabels extends React.Component {
     this.setState({ isTagEditModalShown: false });
   }
 
-  async tagsUpdatedHandler(tags) {
-    const { appContainer, editorContainer, pageContainer } = this.props;
+  async tagsUpdatedHandler(newTags) {
+    const {
+      appContainer, editorContainer, pageContainer, editorMode,
+    } = this.props;
+
     const { pageId } = pageContainer.state;
 
-    // only update tags in editorContainer when new page
-    if (pageId != null) {
-      return editorContainer.setState({ tags });
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === 'edit') {
+      return editorContainer.setState({ tags: newTags });
     }
 
     try {
-      await appContainer.apiPost('/tags.update', { pageId, tags });
+      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) {
@@ -66,7 +73,7 @@ class TagLabels extends React.Component {
 
 
   render() {
-    const tags = this.getEditTargetData();
+    const tags = this.getTagData();
 
     return (
       <>
@@ -107,6 +114,7 @@ TagLabels.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
+  editorMode: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(TagLabelsWrapper);

+ 57 - 99
src/client/js/components/PageAccessoriesModal.jsx

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

+ 17 - 16
src/client/js/components/PageComment/Comment.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 
 import { UncontrolledTooltip } from 'reactstrap';
@@ -16,6 +17,7 @@ import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
 import CommentControl from './CommentControl';
+import HistoryIcon from '../Icons/HistoryIcon';
 
 /**
  *
@@ -38,7 +40,6 @@ class Comment extends React.PureComponent {
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
-    this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
@@ -109,11 +110,6 @@ class Comment extends React.PureComponent {
     return className;
   }
 
-  getRevisionLabelClassName() {
-    return `page-comment-revision badge ${
-      this.isCurrentRevision() ? 'badge-primary' : 'badge-secondary'}`;
-  }
-
   deleteBtnClickedHandler() {
     this.props.deleteBtnClicked(this.props.comment);
   }
@@ -155,6 +151,7 @@ class Comment extends React.PureComponent {
   }
 
   render() {
+    const { t } = this.props;
     const comment = this.props.comment;
     const commentId = comment._id;
     const creator = comment.creator;
@@ -166,8 +163,6 @@ class Comment extends React.PureComponent {
     const rootClassName = this.getRootClassName(comment);
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
-    const revFirst8Letters = comment.revision.substr(-8);
-    const revisionLavelClassName = this.getRevisionLabelClassName();
 
     const editedDateId = `editedDate-${comment._id}`;
     const editedDateFormatted = isEdited
@@ -176,7 +171,6 @@ class Comment extends React.PureComponent {
 
     return (
       <React.Fragment>
-
         {this.state.isReEdit ? (
           <CommentEditor
             growiRenderer={this.props.growiRenderer}
@@ -206,10 +200,17 @@ class Comment extends React.PureComponent {
                     <span id={editedDateId}>&nbsp;(edited)</span>
                     <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
                   </>
-                ) }
-                <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
+                )}
+                <span className="ml-2">
+                  <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
+                    <HistoryIcon />
+                  </a>
+                  <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
+                    {t('page_comment.display_the_page_when_posting_this_comment')}
+                  </UncontrolledTooltip>
+                </span>
               </div>
-              { this.checkPermissionToControlComment() && (
+              {this.checkPermissionToControlComment() && (
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
@@ -217,9 +218,8 @@ class Comment extends React.PureComponent {
               ) }
             </div>
           </div>
-          )
-        }
-
+        )
+      }
       </React.Fragment>
     );
   }
@@ -232,6 +232,7 @@ class Comment extends React.PureComponent {
 const CommentWrapper = withUnstatedContainers(Comment, [AppContainer, PageContainer]);
 
 Comment.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
@@ -240,4 +241,4 @@ Comment.propTypes = {
   deleteBtnClicked: PropTypes.func.isRequired,
 };
 
-export default CommentWrapper;
+export default withTranslation()(CommentWrapper);

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

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
 const TableOfContents = (props) => {
 
-  const { pageContainer, navigationContainer } = props;
+  const { pageContainer, navigationContainer, isGuestUserMode } = props;
 
   const calcViewHeight = useCallback(() => {
     // calculate absolute top of '#revision-toc' element
@@ -42,7 +42,7 @@ const TableOfContents = (props) => {
 
   return (
     <>
-      <TopOfTableContents />
+      <TopOfTableContents isGuestUserMode={isGuestUserMode} />
       <StickyStretchableScroller
         contentsElemSelector=".revision-toc .markdownIt-TOC"
         stickyElemSelector="#revision-toc"
@@ -70,6 +70,8 @@ const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageCont
 TableOfContents.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  isGuestUserMode: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(TableOfContentsWrapper);

+ 27 - 17
src/client/js/components/TopOfTableContents.jsx

@@ -3,11 +3,12 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { UncontrolledTooltip } from 'reactstrap';
 import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
 
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import RecentChangesIcon from './Icons/RecentChangesIcon';
+import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 
@@ -16,16 +17,15 @@ import PageAccessoriesModal from './PageAccessoriesModal';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 const TopOfTableContents = (props) => {
-  const { pageAccessoriesContainer } = props;
+  const { t, pageAccessoriesContainer, isGuestUserMode } = props;
 
   function renderModal() {
     return (
-      <>
-        <PageAccessoriesModal
-          isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-          onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-        />
-      </>
+      <PageAccessoriesModal
+        isGuestUserMode={isGuestUserMode}
+        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
+        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
+      />
     );
   }
 
@@ -53,7 +53,7 @@ const TopOfTableContents = (props) => {
           className="btn btn-link grw-btn-top-of-table"
           onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
         >
-          <RecentChangesIcon />
+          <HistoryIcon />
         </button>
 
         <button
@@ -64,14 +64,20 @@ const TopOfTableContents = (props) => {
           <AttachmentIcon />
         </button>
 
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
-        >
-          <ShareLinkIcon />
-        </button>
-
+        <div id="shareLink-btn-wrapper-for-tooltip">
+          <button
+            type="button"
+            className={`btn btn-link grw-btn-top-of-table ${isGuestUserMode && 'disabled'}`}
+            onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
+          >
+            <ShareLinkIcon />
+          </button>
+        </div>
+        {isGuestUserMode && (
+          <UncontrolledTooltip placement="top" target="shareLink-btn-wrapper-for-tooltip" fade={false}>
+            {t('Not available for guest')}
+          </UncontrolledTooltip>
+        )}
         <div
           id="seen-user-list"
           data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
@@ -90,7 +96,11 @@ const TopOfTableContents = (props) => {
 const TopOfTableContentsWrapper = withUnstatedContainers(TopOfTableContents, [PageAccessoriesContainer]);
 
 TopOfTableContents.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
+  isGuestUserMode: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(TopOfTableContentsWrapper);

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

@@ -11,9 +11,9 @@ const TrashPageList = (props) => {
 
   const navTabMapping = {
     pagelist: {
-      icon: <PageListIcon />,
+      Icon: PageListIcon,
+      Content: PageList,
       i18n: t('page_list'),
-      tabContent: <PageList />,
       index: 0,
     },
   };

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

@@ -202,11 +202,6 @@ $(() => {
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
 
-  // do nothing if user is guest
-  if (appContainer.currentUser == null) {
-    return;
-  }
-
   // hash on page
   if (window.location.hash) {
     const navigationContainer = appContainer.getContainer('NavigationContainer');

+ 9 - 0
src/client/js/services/NavigationContainer.js

@@ -1,4 +1,7 @@
 import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:NavigationContainer');
 
 /**
  * Service container related to options for Application
@@ -86,6 +89,12 @@ export default class NavigationContainer extends Container {
   }
 
   setEditorMode(editorMode) {
+
+    if (this.appContainer.currentUser == null) {
+      logger.warn('Please login or signup to edit the page or use hackmd.');
+      return;
+    }
+
     this.setState({ editorMode });
     if (editorMode === 'view') {
       $('body').removeClass('on-edit');

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

@@ -58,10 +58,10 @@ export default class PageContainer extends Container {
       sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
-      isForbidden:  JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
+      isForbidden: JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
       isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeletable:  JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
-      isCreatable: JSON.parse(mainContent.getAttribute('data-page-is-creatable')),
+      isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isAbleToDeleteCompletely:  JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       tags: null,

+ 5 - 0
src/client/styles/scss/_comment.scss

@@ -40,6 +40,11 @@
       font-size: 0.9em;
       color: $gray-400;
     }
+
+    .page-comment-revision svg {
+      width: 16px;
+      height: 16px;
+    }
   }
 
   .page-comment-main {

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

@@ -44,6 +44,12 @@ textarea.form-control {
   // border: 1px solid darken($border, 30%);
 }
 
+.grw-slack-notification {
+  .form-control {
+    background: $bgcolor-global;
+  }
+}
+
 .form-control[disabled],
 .form-control[readonly] {
   color: lighten($color-global, 10%);

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

@@ -36,6 +36,12 @@ $table-hover-bg: $bgcolor-table-hover;
   background-color: $bgcolor-global;
 }
 
+.grw-slack-notification {
+  .form-control {
+    background: white;
+  }
+}
+
 .form-control::placeholder {
   color: darken($bgcolor-global, 20%);
 }

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

@@ -418,6 +418,17 @@ body.on-edit {
   }
 }
 
+/*
+ * GROWI comment
+ */
+.page-comment-meta .page-comment-revision svg {
+  fill: $color-link;
+
+  &:hover() {
+    fill: $color-link-hover;
+  }
+}
+
 /*
  * GROWI comment form
  */

+ 1 - 2
src/server/routes/apiv3/app-settings.js

@@ -138,7 +138,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
@@ -205,7 +204,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: app settings params
    */
-  router.get('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
     const appSettingsParams = {
       title: crowi.configManager.getConfig('crowi', 'app:title'),
       confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),

+ 4 - 3
src/server/routes/apiv3/page.js

@@ -112,7 +112,8 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  */
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
@@ -165,7 +166,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    */
-  router.put('/likes', accessTokenParser, loginRequired, csrf, validator.likes, apiV3FormValidator, async(req, res) => {
+  router.put('/likes', accessTokenParser, loginRequiredStrictly, csrf, validator.likes, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
 
     let page;
@@ -227,7 +228,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Return page's markdown
   */
-  router.get('/export/:pageId', loginRequired, validator.export, async(req, res) => {
+  router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
     const { pageId } = req.params;
     const { format, revisionId = null } = req.query;
     let revision;

+ 0 - 1
src/server/views/layout-growi/forbidden.html

@@ -5,7 +5,6 @@
   {% include '../widget/page_alerts.html' %}
 {% endblock %}
 
-
 {% block content_main %}
   <div class="row">
     <div class="col grw-page-content-container">

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

@@ -10,6 +10,8 @@
 <div id="content-main" class="content-main page-list"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-is-forbidden="true"
+  data-page-is-not-creatable="true"
   >
 
   <div class="row row-alerts d-edit-none">

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

@@ -10,7 +10,7 @@
 <div id="content-main" class="content-main page-list"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-is-creatable="true"
+  data-page-is-not-creatable="true"
   ></div>
 
 </div>

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

@@ -14,10 +14,10 @@
   data-page-grant-group-name="{{ grantedGroupName }}"
   data-page-is-liked="{% if user %}{{ page.isLiked(user) }}{% else %}false{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-page-is-forbidden="{% if forbidden %}true{% else %}false{% endif %}"
+  data-page-is-forbidden="false"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
-  data-page-is-creatable="false"
+  data-page-is-not-creatable="false"
   data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   data-page-created-at="{% if page %}{{ page.createdAt|datetz('Y/m/d H:i:s') }}{% endif %}"